Merge remote-tracking branch 'upstream/dev' into dev-revised

This commit is contained in:
kunw 2017-03-08 15:36:05 +08:00
commit f2a1659d96
291 changed files with 33570 additions and 1746 deletions

View File

@ -517,7 +517,6 @@ paths:
responses:
200:
description: Get current user information successfully.
in: body
schema:
$ref: '#/definitions/User'
401:
@ -659,6 +658,11 @@ paths:
format: int32
required: true
description: Relevant project ID.
- name: detail
in: query
type: boolean
required: false
description: Get detail info or not.
- name: q
in: query
type: string
@ -1475,6 +1479,31 @@ paths:
description: User does not have permission of admin role.
500:
description: Unexpected internal errors.
/email/ping:
post:
summary: Test connection and authentication with email server.
description: |
Test connection and authentication with email server.
parameters:
- name: settings
in: body
description: Email server settings, if some of the settings are not assigned, they will be read from system configuration.
required: false
schema:
$ref: '#/definitions/EmailServerSetting'
tags:
- Products
responses:
200:
description: Ping email server successfully.
400:
description: Inviald email server settings.
401:
description: User need to login first.
403:
description: Only admin has this authority.
500:
description: Unexpected internal errors.
definitions:
Search:
type: object
@ -1483,26 +1512,12 @@ definitions:
description: Search results of the projects that matched the filter keywords.
type: array
items:
$ref: '#/definitions/SearchProject'
$ref: '#/definitions/Project'
repositories:
description: Search results of the repositories that matched the filter keywords.
type: array
items:
$ref: '#/definitions/SearchRepository'
SearchProject:
type: object
properties:
id:
type: integer
format: int64
description: The ID of project
name:
type: string
description: The name of the project
public:
type: integer
format: int
description: The flag to indicate the publicity of the project (1 is public, 0 is non-public)
SearchRepository:
type: object
properties:
@ -1518,6 +1533,12 @@ definitions:
repository_name:
type: string
description: The name of the repository
pull_count:
type: integer
description: The count how many times the repository is pulled
tags_count:
type: integer
description: The count of tags in the repository
ProjectReq:
type: object
properties:
@ -1983,3 +2004,24 @@ definitions:
error:
type: string
description: fail reason.
EmailServerSetting:
type: object
properties:
email_host:
type: string
description: The host of email server.
email_port:
type: string
description: The port of email server.
email_username:
type: string
description: The username of email server.
email_password:
type: string
description: The password of email server.
email_ssl:
type: string
description: Use ssl/tls or not.
email_identity:
type: string
description: The dentity of email server.

View File

@ -29,7 +29,6 @@ HARBOR_ADMIN_PASSWORD=$harbor_admin_password
PROJECT_CREATION_RESTRICTION=$project_creation_restriction
VERIFY_REMOTE_CERT=$verify_remote_cert
MAX_JOB_WORKERS=$max_job_workers
LOG_DIR=/var/log/jobs
UI_SECRET=$ui_secret
JOBSERVICE_SECRET=$jobservice_secret
TOKEN_EXPIRATION=$token_expiration

View File

@ -62,8 +62,8 @@ http {
return 404;
}
location ~ ^/v2/(.*)/_trust/(.*) {
proxy_pass http://notary-server/v2/$$1/_trust/$$2;
location /notary/v2/ {
proxy_pass http://notary-server/v2/;
proxy_set_header Host $$http_host;
proxy_set_header X-Real-IP $$remote_addr;
proxy_set_header X-Forwarded-For $$proxy_add_x_forwarded_for;
@ -74,6 +74,7 @@ http {
proxy_buffering off;
proxy_request_buffering off;
}
location /v2/ {
proxy_pass http://registry/v2/;
proxy_set_header Host $$http_host;

View File

@ -20,7 +20,7 @@
"type": "token",
"options": {
"realm": "$token_endpoint/service/token",
"service": "harbor-registry",
"service": "harbor-notary",
"issuer": "harbor-token-issuer",
"rootcertbundle": "/config/root.crt"
}

View File

@ -8,6 +8,43 @@ hostname = reg.mydomain.com
#It can be set to https if ssl is enabled on nginx.
ui_url_protocol = http
#The password for the root user of mysql db, change this before any production use.
db_password = root123
#Determine whether the UI should use compressed js files.
#For production, set it to on. For development, set it to off.
use_compressed_js = on
#Maximum number of job workers in job service
max_job_workers = 3
#Determine whether or not to generate certificate for the registry's token.
#If the value is on, the prepare script creates new root cert and private key
#for generating token to access the registry. If the value is off, a key/certificate must
#be supplied for token generation.
customize_crt = on
#Information of your organization for certificate
crt_country = CN
crt_state = State
crt_location = CN
crt_organization = organization
crt_organizationalunit = organizational unit
crt_commonname = example.com
crt_email = example@example.com
#The path of cert and key files for nginx, they are applied only the protocol is set to https
ssl_cert = /data/cert/server.crt
ssl_cert_key = /data/cert/server.key
#The path of secretkey storage
secretkey_path = /data
#NOTES: The properties between BEGIN INITIAL PROPERTIES and END INITIAL PROPERTIES
#only take effect in the first boot, the subsequent changes of these properties
#should be performed on web ui
#************************BEGIN INITIAL PROPERTIES************************
#Email account settings for sending out password resetting emails.
#Email server uses the given username and password to authenticate on TLS connections to host and act as identity.
@ -55,50 +92,19 @@ ldap_scope = 3
#Timeout (in seconds) when connecting to an LDAP Server. The default value (and most reasonable) is 5 seconds.
ldap_timeout = 5
#The password for the root user of mysql db, change this before any production use.
db_password = root123
#Turn on or off the self-registration feature
self_registration = on
#Determine whether the UI should use compressed js files.
#For production, set it to on. For development, set it to off.
use_compressed_js = on
#Maximum number of job workers in job service
max_job_workers = 3
#The expiration time (in minute) of token created by token service, default is 30 minutes
token_expiration = 30
#Determine whether the job service should verify the ssl cert when it connects to a remote registry.
#Set this flag to off when the remote registry uses a self-signed or untrusted certificate.
verify_remote_cert = on
#Determine whether or not to generate certificate for the registry's token.
#If the value is on, the prepare script creates new root cert and private key
#for generating token to access the registry. If the value is off, a key/certificate must
#be supplied for token generation.
customize_crt = on
#Information of your organization for certificate
crt_country = CN
crt_state = State
crt_location = CN
crt_organization = organization
crt_organizationalunit = organizational unit
crt_commonname = example.com
crt_email = example@example.com
#The flag to control what users have permission to create projects
#Be default everyone can create a project, set to "adminonly" such that only admin can create project.
project_creation_restriction = everyone
#The path of cert and key files for nginx, they are applied only the protocol is set to https
ssl_cert = /data/cert/server.crt
ssl_cert_key = /data/cert/server.key
#The path of secretkey storage
secretkey_path = /data
#Determine whether the job service should verify the ssl cert when it connects to a remote registry.
#Set this flag to off when the remote registry uses a self-signed or untrusted certificate.
verify_remote_cert = on
#************************BEGIN INITIAL PROPERTIES************************
#############

View File

@ -46,8 +46,8 @@ var (
comcfg.AdminInitialPassword,
}
// envs are configurations need read from environment variables
envs = map[string]interface{}{
// all configurations need read from environment variables
allEnvs = map[string]interface{}{
comcfg.ExtEndpoint: "EXT_ENDPOINT",
comcfg.AUTHMode: "AUTH_MODE",
comcfg.SelfRegistration: &parser{
@ -96,7 +96,6 @@ var (
env: "TOKEN_EXPIRATION",
parse: parseStringToInt,
},
comcfg.JobLogDir: "LOG_DIR",
comcfg.UseCompressedJS: &parser{
env: "USE_COMPRESSED_JS",
parse: parseStringToBool,
@ -116,6 +115,26 @@ var (
comcfg.ProjectCreationRestriction: "PROJECT_CREATION_RESTRICTION",
comcfg.AdminInitialPassword: "HARBOR_ADMIN_PASSWORD",
}
// configurations need read from environment variables
// every time the system startup
repeatLoadEnvs = map[string]interface{}{
comcfg.ExtEndpoint: "EXT_ENDPOINT",
comcfg.MySQLPassword: "MYSQL_PWD",
comcfg.MaxJobWorkers: &parser{
env: "MAX_JOB_WORKERS",
parse: parseStringToInt,
},
// TODO remove this config?
comcfg.UseCompressedJS: &parser{
env: "USE_COMPRESSED_JS",
parse: parseStringToBool,
},
comcfg.CfgExpiration: &parser{
env: "CFG_EXPIRATION",
parse: parseStringToInt,
},
}
)
type parser struct {
@ -152,16 +171,19 @@ func Init() (err error) {
}
if cfg != nil {
return nil
}
log.Info("configurations read from store driver are null, initializing system from environment variables...")
cfg, err = loadFromEnv()
if err != nil {
return err
if err = loadFromEnv(cfg, false); err != nil {
return err
}
} else {
log.Info("configurations read from store driver are null, initializing system from environment variables...")
cfg = make(map[string]interface{})
if err = loadFromEnv(cfg, true); err != nil {
return err
}
}
//sync configurations into cfg store
log.Info("updating system configurations...")
return UpdateSystemCfg(cfg)
}
@ -198,9 +220,13 @@ func initKeyProvider() {
keyProvider = comcfg.NewFileKeyProvider(path)
}
//load the configurations from env
func loadFromEnv() (map[string]interface{}, error) {
cfg := map[string]interface{}{}
// load the configurations from allEnvs, if all is false, it just loads
// the repeatLoadEnvs
func loadFromEnv(cfg map[string]interface{}, all bool) error {
envs := repeatLoadEnvs
if all {
envs = allEnvs
}
for k, v := range envs {
if str, ok := v.(string); ok {
@ -211,16 +237,16 @@ func loadFromEnv() (map[string]interface{}, error) {
if parser, ok := v.(*parser); ok {
i, err := parser.parse(os.Getenv(parser.env))
if err != nil {
return nil, err
return err
}
cfg[k] = i
continue
}
return nil, fmt.Errorf("%v is not string or parse type", v)
return fmt.Errorf("%v is not string or parse type", v)
}
return cfg, nil
return nil
}
// GetSystemCfg returns the system configurations

View File

@ -119,6 +119,8 @@ func (b *BaseAPI) GetUserIDForRequest() (int, bool, bool) {
user = nil
}
if user != nil {
b.SetSession("userId", user.UserID)
b.SetSession("username", user.Username)
// User login successfully no further check required.
return user.UserID, false, true
}

View File

@ -168,10 +168,15 @@ func ToggleProjectPublicity(projectID int64, publicity int) error {
// 2. the prject is public or the user is a member of the project
func SearchProjects(userID int) ([]models.Project, error) {
o := GetOrmer()
sql := `select distinct p.project_id, p.name, p.public
sql :=
`select distinct p.project_id, p.name, p.public,
p.owner_id, p.creation_time, p.update_time, pm.role role
from project p
left join project_member pm on p.project_id = pm.project_id
where (pm.user_id = ? or p.public = 1) and p.deleted = 0`
left join project_member pm
on p.project_id = pm.project_id
where (pm.user_id = ? or p.public = 1)
and p.deleted = 0 `
var projects []models.Project

View File

@ -50,7 +50,8 @@ func GetRepositoryByName(name string) (*models.RepoRecord, error) {
func GetAllRepositories() ([]models.RepoRecord, error) {
o := GetOrmer()
var repos []models.RepoRecord
_, err := o.QueryTable("repository").All(&repos)
_, err := o.QueryTable("repository").
OrderBy("Name").All(&repos)
return repos, err
}
@ -183,3 +184,34 @@ func GetTotalOfUserRelevantRepositories(userID int, name string) (int64, error)
err := GetOrmer().Raw(sql, params).QueryRow(&total)
return total, err
}
// GetTotalOfRepositoriesByProject ...
func GetTotalOfRepositoriesByProject(projectID int64, name string) (int64, error) {
qs := GetOrmer().QueryTable(&models.RepoRecord{}).
Filter("ProjectID", projectID)
if len(name) != 0 {
qs = qs.Filter("Name__contains", name)
}
return qs.Count()
}
// GetRepositoriesByProject ...
func GetRepositoriesByProject(projectID int64, name string,
limit, offset int64) ([]*models.RepoRecord, error) {
repositories := []*models.RepoRecord{}
qs := GetOrmer().QueryTable(&models.RepoRecord{}).
Filter("ProjectID", projectID)
if len(name) != 0 {
qs = qs.Filter("Name__contains", name)
}
_, err := qs.Limit(limit).
Offset(offset).All(&repositories)
return repositories, err
}

View File

@ -340,6 +340,78 @@ func TestGetTopRepos(t *testing.T) {
})
}
func TestGetTotalOfRepositoriesByProject(t *testing.T) {
var projectID int64 = 1
repoName := "library/total_count"
total, err := GetTotalOfRepositoriesByProject(projectID, repoName)
if err != nil {
t.Errorf("failed to get total of repositoreis of project %d: %v", projectID, err)
return
}
if err := addRepository(&models.RepoRecord{
Name: repoName,
OwnerName: "admin",
ProjectName: "library",
}); err != nil {
t.Errorf("failed to add repository %s: %v", repoName, err)
return
}
defer func() {
if err := deleteRepository(repoName); err != nil {
t.Errorf("failed to delete repository %s: %v", name, err)
return
}
}()
n, err := GetTotalOfRepositoriesByProject(projectID, repoName)
if err != nil {
t.Errorf("failed to get total of repositoreis of project %d: %v", projectID, err)
return
}
if n != total+1 {
t.Errorf("unexpected total: %d != %d", n, total+1)
}
}
func TestGetRepositoriesByProject(t *testing.T) {
var projectID int64 = 1
repoName := "library/repository"
if err := addRepository(&models.RepoRecord{
Name: repoName,
OwnerName: "admin",
ProjectName: "library",
}); err != nil {
t.Errorf("failed to add repository %s: %v", repoName, err)
return
}
defer func() {
if err := deleteRepository(repoName); err != nil {
t.Errorf("failed to delete repository %s: %v", name, err)
return
}
}()
repositories, err := GetRepositoriesByProject(projectID, repoName, 10, 0)
if err != nil {
t.Errorf("failed to get repositoreis of project %d: %v", projectID, err)
return
}
t.Log(repositories)
for _, repository := range repositories {
if repository.Name == repoName {
return
}
}
t.Errorf("repository %s not found", repoName)
}
func addRepository(repository *models.RepoRecord) error {
return AddRepository(*repository)
}

View File

@ -17,15 +17,16 @@ package email
import (
"bytes"
"crypto/tls"
tlspkg "crypto/tls"
"net"
"strconv"
//"strings"
"time"
"net/smtp"
"text/template"
//"github.com/astaxie/beego"
"github.com/vmware/harbor/src/common/models"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/ui/config"
)
@ -37,11 +38,12 @@ type Mail struct {
Message string
}
var mc models.Email
var mc *models.Email
// SendMail sends Email according to the configurations
func (m Mail) SendMail() error {
mc, err := config.Email()
var err error
mc, err = config.Email()
if err != nil {
return err
}
@ -72,7 +74,7 @@ func sendMail(m Mail, auth smtp.Auth, content []byte) error {
}
func sendMailWithTLS(m Mail, auth smtp.Auth, content []byte) error {
conn, err := tls.Dial("tcp", mc.Host+":"+strconv.Itoa(mc.Port), nil)
conn, err := tlspkg.Dial("tcp", mc.Host+":"+strconv.Itoa(mc.Port), nil)
if err != nil {
return err
}
@ -117,24 +119,76 @@ func sendMailWithTLS(m Mail, auth smtp.Auth, content []byte) error {
return client.Quit()
}
/*
func loadConfig() {
config, err := beego.AppConfig.GetSection("mail")
// Ping tests the connection and authentication with email server
// If tls is true, a secure connection is established, or Ping
// trys to upgrate the insecure connection to a secure one if
// email server supports it.
// Ping doesn't verify the server's certificate and hostname when
// needed if the parameter insecure is ture
func Ping(addr, identity, username, password string,
timeout int, tls, insecure bool) (err error) {
log.Debugf("establishing TCP connection with %s ...", addr)
conn, err := net.DialTimeout("tcp", addr,
time.Duration(timeout)*time.Second)
if err != nil {
panic(err)
return
}
defer conn.Close()
host, _, err := net.SplitHostPort(addr)
if err != nil {
return
}
var useTLS = false
if config["ssl"] != "" && strings.ToLower(config["ssl"]) == "true" {
useTLS = true
if tls {
log.Debugf("establishing SSL/TLS connection with %s ...", addr)
tlsConn := tlspkg.Client(conn, &tlspkg.Config{
ServerName: host,
InsecureSkipVerify: insecure,
})
if err = tlsConn.Handshake(); err != nil {
return
}
defer tlsConn.Close()
conn = tlsConn
}
mc = MailConfig{
Identity: config["identity"],
Host: config["host"],
Port: config["port"],
Username: config["username"],
Password: config["password"],
TLS: useTLS,
log.Debugf("creating SMTP client for %s ...", host)
client, err := smtp.NewClient(conn, host)
if err != nil {
return
}
defer client.Close()
//try to swith to SSL/TLS
if !tls {
if ok, _ := client.Extension("STARTTLS"); ok {
log.Debugf("switching the connection with %s to SSL/TLS ...", addr)
if err = client.StartTLS(&tlspkg.Config{
ServerName: host,
InsecureSkipVerify: insecure,
}); err != nil {
return
}
} else {
log.Debugf("the email server %s does not support STARTTLS", addr)
}
}
if ok, _ := client.Extension("AUTH"); ok {
log.Debug("authenticating the client...")
// only support plain auth
if err = client.Auth(smtp.PlainAuth(identity,
username, password, host)); err != nil {
return
}
} else {
log.Debugf("the email server %s does not support AUTH, skip",
addr)
}
log.Debug("ping email server successfully")
return
}
*/

View File

@ -0,0 +1,42 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package email
import (
"strings"
"testing"
)
func TestPing(t *testing.T) {
addr := "smtp.gmail.com:465"
identity := ""
username := "wrong_username"
password := "wrong_password"
timeout := 60
tls := true
insecure := false
// test secure connection
err := Ping(addr, identity, username, password,
timeout, tls, insecure)
if err == nil {
t.Errorf("there should be an auth error")
} else {
if !strings.Contains(err.Error(), "535") {
t.Errorf("unexpected error: %v", err)
}
}
}

View File

@ -0,0 +1,82 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package notary
import (
"strings"
"github.com/docker/notary"
"github.com/docker/notary/client"
"github.com/docker/notary/trustpinning"
"github.com/docker/notary/tuf/data"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/common/utils/registry"
"github.com/vmware/harbor/src/common/utils/registry/auth"
)
var (
notaryEndpoint = "http://notary-server:4443"
notaryCachePath = "/root/notary"
trustPin trustpinning.TrustPinConfig
mockRetriever notary.PassRetriever
)
// Target represents the json object of a target of a docker image in notary.
// The struct will be used when repository is know so it won'g contain the name of a repository.
type Target struct {
Tag string `json:"tag"`
Hashes data.Hashes `json:"hashes"`
//TODO: update fields as needed.
}
func init() {
mockRetriever = func(keyName, alias string, createNew bool, attempts int) (passphrase string, giveup bool, err error) {
passphrase = "hardcode"
giveup = false
err = nil
return
}
trustPin = trustpinning.TrustPinConfig{}
}
// GetTargets is a help function called by API to fetch signature information of a given repository.
// Per docker's convention the repository should contain the information of endpoint, i.e. it should look
// like "10.117.4.117/library/ubuntu", instead of "library/ubuntu" (fqRepo for fully-qualified repo)
func GetTargets(username string, fqRepo string) ([]Target, error) {
res := []Target{}
authorizer := auth.NewNotaryUsernameTokenAuthorizer(username, "repository", fqRepo, "pull")
store, err := auth.NewAuthorizerStore(strings.Split(notaryEndpoint, "//")[1], true, authorizer)
if err != nil {
return res, err
}
tr := registry.NewTransport(registry.GetHTTPTransport(true), store)
gun := data.GUN(fqRepo)
notaryRepo, err := client.NewFileCachedNotaryRepository(notaryCachePath, gun, notaryEndpoint, tr, mockRetriever, trustPin)
if err != nil {
return res, err
}
targets, err := notaryRepo.ListTargets(data.CanonicalTargetsRole)
if _, ok := err.(client.ErrRepositoryNotExist); ok {
log.Errorf("Repository not exist, repo: %s, error: %v, returning empty signature", fqRepo, err)
return res, nil
} else if err != nil {
return res, err
}
for _, t := range targets {
res = append(res, Target{t.Name, t.Hashes})
}
return res, nil
}

View File

@ -0,0 +1,31 @@
package notary
import (
"fmt"
"github.com/stretchr/testify/assert"
notarytest "github.com/vmware/harbor/src/common/utils/notary/test"
"path"
"testing"
)
var endpoint = "10.117.4.142"
func TestMain(m *testing.M) {
notaryServer := notarytest.NewNotaryServer(endpoint)
defer notaryServer.Close()
notaryEndpoint = notaryServer.URL
notaryCachePath = "/tmp/notary"
m.Run()
}
func TestGetTargets(t *testing.T) {
targets, err := GetTargets("admin", path.Join(endpoint, "notary-demo/busybox"))
assert.Nil(t, err, fmt.Sprintf("Unexpected error: %v", err))
assert.Equal(t, 1, len(targets), "")
assert.Equal(t, "1.0", targets[0].Tag, "")
targets, err = GetTargets("admin", path.Join(endpoint, "notary-demo/notexist"))
assert.Nil(t, err, fmt.Sprintf("Unexpected error: %v", err))
assert.Equal(t, 0, len(targets), "Targets list should be empty for non exist repo.")
}

View File

@ -0,0 +1,44 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package test
import (
"fmt"
"net/http"
"net/http/httptest"
"path"
"runtime"
)
func currPath() string {
_, f, _, ok := runtime.Caller(0)
if !ok {
panic("Failed to get current directory")
}
return path.Dir(f)
}
// NewNotaryServer creates a notary server for testing.
func NewNotaryServer(endpoint string) *httptest.Server {
mux := http.NewServeMux()
validRoot := fmt.Sprintf("/v2/%s/notary-demo/busybox/_trust/tuf/", endpoint)
invalidRoot := fmt.Sprintf("/v2/%s/notary-demo/fail/_trust/tuf/", endpoint)
p := currPath()
fmt.Printf("valid web root: %s, local path: %s\n", validRoot, path.Join(p, "valid"))
mux.Handle(validRoot, http.StripPrefix(validRoot, http.FileServer(http.Dir(path.Join(p, "valid")))))
mux.Handle(invalidRoot, http.StripPrefix(invalidRoot, http.FileServer(http.Dir(path.Join(p, "invalid")))))
return httptest.NewServer(mux)
}

View File

@ -0,0 +1 @@
{"signed":{"_type":"Root","consistent_snapshot":false,"expires":"2027-02-26T20:58:40.741161013+08:00","keys":{"54c7a86e6f03a093c432c6f31d8cfcbc8637bb4ee9223de8a68971ddb9b53b35":{"keytype":"ecdsa","keyval":{"private":null,"public":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYgQ4QwWAHPDTlQvTSPyEDw0aAI9n9PY0hLtkgv2nbGo/mE5Da9gFX4o1wG8CNtzRWEf8RnHL1tpmmhQkRx5Byw=="}},"756dc9faa625646ff80e26a25e05e3df88254e9be68b92b68dbfea7b4697292e":{"keytype":"ecdsa-x509","keyval":{"private":null,"public":"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpVENDQVMrZ0F3SUJBZ0lRZFlWTnZQbGtqdHlCSjlaT3YxaDQ4VEFLQmdncWhrak9QUVFEQWpBck1Ta3cKSndZRFZRUURFeUF4TUM0eE1UY3VOQzR4TkRJdmJtOTBZWEo1TFdSbGJXOHZZblZ6ZVdKdmVEQWVGdzB4TnpBeQpNamd4TWpVNE16aGFGdzB5TnpBeU1qWXhNalU0TXpoYU1Dc3hLVEFuQmdOVkJBTVRJREV3TGpFeE55NDBMakUwCk1pOXViM1JoY25rdFpHVnRieTlpZFhONVltOTRNRmt3RXdZSEtvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUUKNktZRzdIeC90SHJPMUh6SkRuTSs0SmdyMFJBWWR3N0w5MVhMRTJHV0lCeUJjSTRXMktSQlMxUHY4RlQwd2V4Kwo1cHNvZGZtcTdObWFCYitUQU85ZWRhTTFNRE13RGdZRFZSMFBBUUgvQkFRREFnV2dNQk1HQTFVZEpRUU1NQW9HCkNDc0dBUVVGQndNRE1Bd0dBMVVkRXdFQi93UUNNQUF3Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUlnT25rNENWelgKU2dacFZjSy9wa01VTWFmOUpCeGRidHgvTkNxRWJpaHJUbEFDSVFEbytudkh6azF1SURLUlc5c01ZNG5zaUtxSAprcUR4UEhaRGlZVXE0UExoOHc9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="}},"8230fd1fbdf1d7de675cd93ec0a64685a63f0db50a65217555f321939efe59df":{"keytype":"ecdsa","keyval":{"private":null,"public":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEDkmdEwFUhMC+NRy3TuchNprTD8HoRUE+X5RPxevxdl3qcWIFk+26GIYYMMTqFcsmDzaoGXqixdqcJA5WaTg79A=="}},"f45d9afcbb5afd810369f5c2ef84477b75502c22867e66e5f69465f18c6ae157":{"keytype":"ecdsa","keyval":{"private":null,"public":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESWYg9Ix/pC2lVu1WTBQ0obYVdT+P9Xh6qMkvD1YVv0t28vxoiKYobmVwAfOzdERfXTJM9jIbZwP94Q41HQB2Zg=="}}},"roles":{"root":{"keyids":["756dc9faa625646ff80e26a25e05e3df88254e9be68b92b68dbfea7b4697292e"],"threshold":1},"snapshot":{"keyids":["8230fd1fbdf1d7de675cd93ec0a64685a63f0db50a65217555f321939efe59df"],"threshold":1},"targets":{"keyids":["f45d9afcbb5afd810369f5c2ef84477b75502c22867e66e5f69465f18c6ae157"],"threshold":1},"timestamp":{"keyids":["54c7a86e6f03a093c432c6f31d8cfcbc8637bb4ee9223de8a68971ddb9b53b35"],"threshold":1}},"version":1},"signatures":[{"keyid":"756dc9faa625646ff80e26a25e05e3df88254e9be68b92b68dbfea7b4697292e","method":"ecdsa","sig":"b1V3xDWGp0YNFde9Hgx4yipiebzZedhBaVRJSfxKsjxRmFmvNty8hvTL1D7mURZkc7FJPcsN/o3xC9AlUz/isQ=="}]}

View File

@ -0,0 +1 @@
{"signed":{"_type":"Snapshot","expires":"2020-02-28T12:58:40.793595145Z","meta":{"root":{"hashes":{"sha256":"yy0hAbWa41HhHIVfmexYbqqzSc60ZB8Vz55BdPjEYxI=","sha512":"kT7I5pFqI35onn6tghIcwSR24jVgxQG/rF+Ct4eFJBpr7J8kMKL86vcD+pjbWkh/px6oPRm89+34i45woam89w=="},"length":2429},"targets":{"hashes":{"sha256":"iBYw3fp614v3WkMfhrf2gYLgsSAcS8bSuNHy3eQAKzA=","sha512":"ZC78eLxWK4o8PZoAkDOyxN8ggBDpLKXHKZUdu558B/lYVYFmXpdzKglw/87hE/jDXbfct1sh2EBmD68ESLjG1Q=="},"length":433}},"version":1},"signatures":[{"keyid":"8230fd1fbdf1d7de675cd93ec0a64685a63f0db50a65217555f321939efe59df","method":"ecdsa","sig":"/cx8YA5vwxRckZQjUQxQ+OghKEy1R2Ha8m1oHLtEfvqzKIKNyZvNo3I9AMKMDgukz85JDKRS7zFg88jkkghleA=="}]}

View File

@ -0,0 +1 @@
{"signed":{"_type":"Targets","delegations":{"keys":{},"roles":[]},"expires":"2020-02-28T20:58:40.770351325+08:00","targets":{"1.0":{"hashes":{"sha256":"E1lggRW5RZnlZBY4usWu8d36p5u5YFfr9B68jTOs+Kc="},"length":527}},"version":2},"signatures":[{"keyid":"f45d9afcbb5afd810369f5c2ef84477b75502c22867e66e5f69465f18c6ae157","method":"ecdsa","sig":"T070LEVEi5cdA1RRt0MOeYlxl+KEAyfa8uGkD0OJI/V9OQlh12aDDu7H6qhR5qk1LmQpwTHBOtdEnjZd2bkN6w=="}]}

View File

@ -0,0 +1 @@
{"signed":{"_type":"Timestamp","expires":"2017-03-14T12:58:40.833773016Z","meta":{"snapshot":{"hashes":{"sha256":"fPRwHL3qeGxQnGpBdKd7JcBoHMjSbQcUnpF0TTiq8io=","sha512":"vbJK5eX8iSIjWu2nZAHLzHLuETvz6kSzCOoYyn1C86BgieMHFpZmLgEj7AKuG9svBCYc17nii3B8ROfiLvNHaQ=="},"length":683}},"version":1},"signatures":[{"keyid":"54c7a86e6f03a093c432c6f31d8cfcbc8637bb4ee9223de8a68971ddb9b53b35","method":"ecdsa","sig":"fK6IF/jcigZ2mz5kqqb9Yma97zUOGB4OQqDfxQcAskW7DhpsKIWB1l+E7m0IPFBbIrL8q9l0GjlumCNVptmauw=="}]}

View File

@ -252,9 +252,21 @@ type usernameTokenAuthorizer struct {
username string
}
// NewUsernameTokenAuthorizer returns a authorizer which will generate a token according to
// NewRegistryUsernameTokenAuthorizer returns an authorizer to generate token for registry according to
// the user's privileges
func NewUsernameTokenAuthorizer(username string, scopeType, scopeName string, scopeActions ...string) Authorizer {
func NewRegistryUsernameTokenAuthorizer(username, scopeType, scopeName string, scopeActions ...string) Authorizer {
return newUsernameTokenAuthorizer(false, username, scopeType, scopeName, scopeActions...)
}
// NewNotaryUsernameTokenAuthorizer returns an authorizer to generate token for notary according to
// the user's privileges
func NewNotaryUsernameTokenAuthorizer(username, scopeType, scopeName string, scopeActions ...string) Authorizer {
return newUsernameTokenAuthorizer(true, username, scopeType, scopeName, scopeActions...)
}
// newUsernameTokenAuthorizer returns a authorizer which will generate a token according to
// the user's privileges
func newUsernameTokenAuthorizer(notary bool, username, scopeType, scopeName string, scopeActions ...string) Authorizer {
authorizer := &usernameTokenAuthorizer{
username: username,
}
@ -264,13 +276,25 @@ func NewUsernameTokenAuthorizer(username string, scopeType, scopeName string, sc
Name: scopeName,
Actions: scopeActions,
}
authorizer.tg = authorizer.generateToken
if notary {
authorizer.tg = authorizer.genNotaryToken
} else {
authorizer.tg = authorizer.genRegistryToken
}
return authorizer
}
func (u *usernameTokenAuthorizer) generateToken(realm, service string, scopes []string) (token string, expiresIn int, issuedAt *time.Time, err error) {
token, expiresIn, issuedAt, err = token_util.GenTokenForUI(u.username, service, scopes)
token, expiresIn, issuedAt, err = token_util.RegistryTokenForUI(u.username, service, scopes)
return
}
func (u *usernameTokenAuthorizer) genRegistryToken(realm, service string, scopes []string) (token string, expiresIn int, issuedAt *time.Time, err error) {
token, expiresIn, issuedAt, err = token_util.RegistryTokenForUI(u.username, service, scopes)
return
}
func (u *usernameTokenAuthorizer) genNotaryToken(realm, service string, scopes []string) (token string, expiresIn int, issuedAt *time.Time, err error) {
token, expiresIn, issuedAt, err = token_util.NotaryTokenForUI(u.username, service, scopes)
return
}

View File

@ -56,7 +56,6 @@ var adminServerDefaultConfig = map[string]interface{}{
config.MaxJobWorkers: 3,
config.TokenExpiration: 30,
config.CfgExpiration: 5,
config.JobLogDir: "/var/log/jobs",
config.UseCompressedJS: true,
config.AdminInitialPassword: "password",
}

View File

@ -25,6 +25,7 @@ import (
const (
defaultKeyPath string = "/etc/jobservice/key"
defaultLogDir string = "/var/log/jobs"
)
var (
@ -119,12 +120,12 @@ func LocalRegURL() (string, error) {
}
// LogDir returns the absolute path to which the log file will be written
func LogDir() (string, error) {
cfg, err := mg.Get()
if err != nil {
return "", err
func LogDir() string {
dir := os.Getenv("LOG_DIR")
if len(dir) == 0 {
dir = defaultLogDir
}
return cfg[comcfg.JobLogDir].(string), nil
return dir
}
// SecretKey will return the secret key for encryption/decryption password in target.

View File

@ -66,8 +66,8 @@ func TestConfig(t *testing.T) {
t.Fatalf("failed to get registry URL: %v", err)
}
if _, err := LogDir(); err != nil {
t.Fatalf("failed to get log directory: %v", err)
if dir := LogDir(); dir != "/var/log/jobs" {
t.Errorf("unexpected log directory: %s != %s", dir, "/var/log/jobs")
}
if _, err := SecretKey(); err != nil {

View File

@ -65,10 +65,6 @@ func GetJobLogPath(jobID int64) (string, error) {
p = filepath.Join(d, p)
}
base, err := config.LogDir()
if err != nil {
return "", err
}
p = filepath.Join(base, p, f)
p = filepath.Join(config.LogDir(), p, f)
return p, nil
}

117
src/ui/api/email.go Normal file
View File

@ -0,0 +1,117 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package api
import (
"fmt"
"net"
"net/http"
"strconv"
"github.com/vmware/harbor/src/common/api"
comcfg "github.com/vmware/harbor/src/common/config"
"github.com/vmware/harbor/src/common/dao"
"github.com/vmware/harbor/src/common/utils/email"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/ui/config"
)
const (
pingEmailTimeout = 60
)
// EmailAPI ...
type EmailAPI struct {
api.BaseAPI
}
// Prepare ...
func (e *EmailAPI) Prepare() {
userID := e.ValidateUser()
isSysAdmin, err := dao.IsAdminRole(userID)
if err != nil {
log.Errorf("failed to check the role of user: %v", err)
e.CustomAbort(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
}
if !isSysAdmin {
e.CustomAbort(http.StatusForbidden, http.StatusText(http.StatusForbidden))
}
}
// Ping tests connection and authentication with email server
func (e *EmailAPI) Ping() {
m := map[string]string{}
e.DecodeJSONReq(&m)
settings, err := config.Email()
if err != nil {
e.CustomAbort(http.StatusInternalServerError,
http.StatusText(http.StatusInternalServerError))
}
host, ok := m[comcfg.EmailHost]
if ok {
if len(host) == 0 {
e.CustomAbort(http.StatusBadRequest, "empty email server host")
}
settings.Host = host
}
port, ok := m[comcfg.EmailPort]
if ok {
if len(port) == 0 {
e.CustomAbort(http.StatusBadRequest, "empty email server port")
}
p, err := strconv.Atoi(port)
if err != nil || p <= 0 {
e.CustomAbort(http.StatusBadRequest, "invalid email server port")
}
settings.Port = p
}
username, ok := m[comcfg.EmailUsername]
if ok {
settings.Username = username
}
password, ok := m[comcfg.EmailPassword]
if ok {
settings.Password = password
}
identity, ok := m[comcfg.EmailIdentity]
if ok {
settings.Identity = identity
}
ssl, ok := m[comcfg.EmailSSL]
if ok {
if ssl != "0" && ssl != "1" {
e.CustomAbort(http.StatusBadRequest,
fmt.Sprintf("%s should be 0 or 1", comcfg.EmailSSL))
}
settings.SSL = ssl == "1"
}
addr := net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
if err := email.Ping(
addr, settings.Identity, settings.Username,
settings.Password, pingEmailTimeout, settings.SSL, false); err != nil {
log.Debugf("ping %s failed: %v", addr, err)
e.CustomAbort(http.StatusBadRequest, err.Error())
}
}

63
src/ui/api/email_test.go Normal file
View File

@ -0,0 +1,63 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package api
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
comcfg "github.com/vmware/harbor/src/common/config"
)
func TestPingEmail(t *testing.T) {
fmt.Println("Testing ping email server")
assert := assert.New(t)
apiTest := newHarborAPI()
//case 1: ping email server without admin role
code, _, err := apiTest.PingEmail(*testUser, nil)
if err != nil {
t.Errorf("failed to test ping email server: %v", err)
return
}
assert.Equal(401, code, "the status code of ping email server with non-admin user should be 401")
settings := map[string]string{
comcfg.EmailHost: "smtp.gmail.com",
comcfg.EmailPort: "465",
comcfg.EmailIdentity: "",
comcfg.EmailUsername: "wrong_username",
comcfg.EmailPassword: "wrong_password",
comcfg.EmailSSL: "1",
}
//case 2: secure connection with admin role
code, body, err := apiTest.PingEmail(*admin, settings)
if err != nil {
t.Errorf("failed to test ping email server: %v", err)
return
}
assert.Equal(400, code, "the status code of ping email server should be 400")
if !strings.Contains(body, "535") {
t.Errorf("unexpected error: %s does not contains 535", body)
return
}
}

View File

@ -100,6 +100,7 @@ func init() {
beego.Router("/api/systeminfo/getcert", &SystemInfoAPI{}, "get:GetCert")
beego.Router("/api/ldap/ping", &LdapAPI{}, "post:Ping")
beego.Router("/api/configurations", &ConfigAPI{})
beego.Router("/api/email/ping", &EmailAPI{}, "post:Ping")
_ = updateInitPassword(1, "Harbor12345")
@ -452,7 +453,7 @@ func (a testapi) PutProjectMember(authInfo usrInfo, projectID string, userID str
//-------------------------Repositories Test---------------------------------------//
//Return relevant repos of projectID
func (a testapi) GetRepos(authInfo usrInfo, projectID string) (int, error) {
func (a testapi) GetRepos(authInfo usrInfo, projectID, detail string) (int, error) {
_sling := sling.New().Get(a.basePath)
path := "/api/repositories/"
@ -461,9 +462,13 @@ func (a testapi) GetRepos(authInfo usrInfo, projectID string) (int, error) {
type QueryParams struct {
ProjectID string `url:"project_id"`
Detail string `url:"detail"`
}
_sling = _sling.QueryStruct(&QueryParams{ProjectID: projectID})
_sling = _sling.QueryStruct(&QueryParams{
ProjectID: projectID,
Detail: detail,
})
httpStatusCode, _, err := request(_sling, jsonAcceptHeader, authInfo)
return httpStatusCode, err
}
@ -943,3 +948,11 @@ func (a testapi) PutConfig(authInfo usrInfo, cfg map[string]string) (int, error)
return code, err
}
func (a testapi) PingEmail(authInfo usrInfo, settings map[string]string) (int, string, error) {
_sling := sling.New().Base(a.basePath).Post("/api/email/ping").BodyJSON(settings)
code, body, err := request(_sling, jsonAcceptHeader, authInfo)
return code, string(body), err
}

View File

@ -19,23 +19,24 @@ import (
"fmt"
"io/ioutil"
"net/http"
"path"
"sort"
"strings"
"time"
"github.com/docker/distribution/manifest/schema1"
"github.com/docker/distribution/manifest/schema2"
"github.com/vmware/harbor/src/common/api"
"github.com/vmware/harbor/src/common/dao"
"github.com/vmware/harbor/src/common/models"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/common/utils/registry"
"github.com/vmware/harbor/src/ui/service/cache"
svc_utils "github.com/vmware/harbor/src/ui/service/utils"
registry_error "github.com/vmware/harbor/src/common/utils/registry/error"
"github.com/vmware/harbor/src/common/utils"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/common/utils/notary"
"github.com/vmware/harbor/src/common/utils/registry"
"github.com/vmware/harbor/src/common/utils/registry/auth"
registry_error "github.com/vmware/harbor/src/common/utils/registry/error"
"github.com/vmware/harbor/src/ui/config"
svc_utils "github.com/vmware/harbor/src/ui/service/utils"
)
// RepositoryAPI handles request to /api/repositories /api/repositories/tags /api/repositories/manifests, the parm has to be put
@ -44,6 +45,19 @@ type RepositoryAPI struct {
api.BaseAPI
}
type repoResp struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"owner_id"`
ProjectID int64 `json:"project_id"`
Description string `json:"description"`
PullCount int64 `json:"pull_count"`
StarCount int64 `json:"star_count"`
TagsCount int64 `json:"tags_count"`
CreationTime time.Time `json:"creation_time"`
UpdateTime time.Time `json:"update_time"`
}
// Get ...
func (ra *RepositoryAPI) Get() {
projectID, err := ra.GetInt64("project_id")
@ -51,8 +65,6 @@ func (ra *RepositoryAPI) Get() {
ra.CustomAbort(http.StatusBadRequest, "invalid project_id")
}
page, pageSize := ra.GetPaginationParams()
project, err := dao.GetProjectByID(projectID)
if err != nil {
log.Errorf("failed to get project %d: %v", projectID, err)
@ -77,30 +89,71 @@ func (ra *RepositoryAPI) Get() {
}
}
repositories, err := getReposByProject(project.Name, ra.GetString("q"))
keyword := ra.GetString("q")
total, err := dao.GetTotalOfRepositoriesByProject(projectID, keyword)
if err != nil {
log.Errorf("failed to get total of repositories of project %d: %v", projectID, err)
ra.CustomAbort(http.StatusInternalServerError, "")
}
page, pageSize := ra.GetPaginationParams()
detail := ra.GetString("detail") == "1" || ra.GetString("detail") == "true"
repositories, err := getRepositories(projectID,
keyword, pageSize, pageSize*(page-1), detail)
if err != nil {
log.Errorf("failed to get repository: %v", err)
ra.CustomAbort(http.StatusInternalServerError, "")
}
total := int64(len(repositories))
if (page-1)*pageSize > total {
repositories = []string{}
} else {
repositories = repositories[(page-1)*pageSize:]
}
if page*pageSize <= total {
repositories = repositories[:pageSize]
}
ra.SetPaginationHeader(total, page, pageSize)
ra.Data["json"] = repositories
ra.ServeJSON()
}
func getRepositories(projectID int64, keyword string,
limit, offset int64, detail bool) (interface{}, error) {
repositories, err := dao.GetRepositoriesByProject(projectID, keyword, limit, offset)
if err != nil {
return nil, err
}
//keep compatibility with old API
if !detail {
result := []string{}
for _, repository := range repositories {
result = append(result, repository.Name)
}
return result, nil
}
result := []*repoResp{}
for _, repository := range repositories {
repo := &repoResp{
ID: repository.RepositoryID,
Name: repository.Name,
OwnerID: repository.OwnerID,
ProjectID: repository.ProjectID,
Description: repository.Description,
PullCount: repository.PullCount,
StarCount: repository.StarCount,
CreationTime: repository.CreationTime,
UpdateTime: repository.UpdateTime,
}
tags, err := getTags(repository.Name)
if err != nil {
return nil, err
}
repo.TagsCount = int64(len(tags))
result = append(result, repo)
}
return result, nil
}
// Delete ...
func (ra *RepositoryAPI) Delete() {
repoName := ra.GetString("repo_name")
@ -119,11 +172,9 @@ func (ra *RepositoryAPI) Delete() {
ra.CustomAbort(http.StatusNotFound, fmt.Sprintf("project %s not found", projectName))
}
if project.Public == 0 {
userID := ra.ValidateUser()
if !hasProjectAdminRole(userID, project.ProjectID) {
ra.CustomAbort(http.StatusForbidden, "")
}
userID := ra.ValidateUser()
if !hasProjectAdminRole(userID, project.ProjectID) {
ra.CustomAbort(http.StatusForbidden, "")
}
rc, err := ra.initRepositoryClient(repoName)
@ -195,13 +246,6 @@ func (ra *RepositoryAPI) Delete() {
ra.CustomAbort(http.StatusInternalServerError, "")
}
}
go func() {
log.Debug("refreshing catalog cache")
if err := cache.RefreshCatalogCache(); err != nil {
log.Errorf("error occurred while refresh catalog cache: %v", err)
}
}()
}
type tag struct {
@ -234,36 +278,46 @@ func (ra *RepositoryAPI) GetTags() {
}
}
rc, err := ra.initRepositoryClient(repoName)
client, err := ra.initRepositoryClient(repoName)
if err != nil {
log.Errorf("error occurred while initializing repository client for %s: %v", repoName, err)
ra.CustomAbort(http.StatusInternalServerError, "internal error")
}
tags := []string{}
ts, err := rc.ListTag()
tags, err := listTag(client)
if err != nil {
regErr, ok := err.(*registry_error.Error)
if !ok {
log.Errorf("error occurred while listing tags of %s: %v", repoName, err)
ra.CustomAbort(http.StatusInternalServerError, "internal error")
}
ra.CustomAbort(regErr.StatusCode, regErr.Detail)
}
ra.Data["json"] = tags
ra.ServeJSON()
}
func listTag(client *registry.Repository) ([]string, error) {
tags := []string{}
ts, err := client.ListTag()
if err != nil {
// TODO remove the logic if the bug of registry is fixed
// It's a workaround for a bug of registry: when listing tags of
// a repository which is being pushed, a "NAME_UNKNOWN" error will
// been returned, while the catalog API can list this repository.
if regErr.StatusCode != http.StatusNotFound {
ra.CustomAbort(regErr.StatusCode, regErr.Detail)
if regErr, ok := err.(*registry_error.Error); ok &&
regErr.StatusCode == http.StatusNotFound {
return tags, nil
}
}
tags = append(tags, ts...)
sort.Strings(tags)
ra.Data["json"] = tags
ra.ServeJSON()
return tags, nil
}
// GetManifests handles GET /api/repositories/manifests
@ -382,7 +436,7 @@ func (ra *RepositoryAPI) initRepositoryClient(repoName string) (r *registry.Repo
return nil, err
}
return cache.NewRepositoryClient(endpoint, !verify, username, repoName,
return NewRepositoryClient(endpoint, !verify, username, repoName,
"repository", repoName, "pull", "push", "*")
}
@ -438,6 +492,34 @@ func (ra *RepositoryAPI) GetTopRepos() {
ra.ServeJSON()
}
//GetSignatures handles request GET /api/repositories/signatures
func (ra *RepositoryAPI) GetSignatures() {
//use this func to init session.
ra.GetUserIDForRequest()
repoName := ra.GetString("repo_name")
if len(repoName) == 0 {
ra.CustomAbort(http.StatusBadRequest, "repo_name is nil")
}
ext, err := config.ExtEndpoint()
if err != nil {
log.Errorf("Error while reading external endpoint: %v", err)
ra.CustomAbort(http.StatusInternalServerError, "internal error")
}
endpoint := strings.Split(ext, "//")[1]
fqRepo := path.Join(endpoint, repoName)
username, err := ra.getUsername()
if err != nil {
log.Warningf("Error when getting username: %v", err)
}
targets, err := notary.GetTargets(username, fqRepo)
if err != nil {
log.Errorf("Error while fetching signature from notary: %v", err)
ra.CustomAbort(http.StatusInternalServerError, "internal error")
}
ra.Data["json"] = targets
ra.ServeJSON()
}
func newRepositoryClient(endpoint string, insecure bool, username, password, repository, scopeType, scopeName string,
scopeActions ...string) (*registry.Repository, error) {

View File

@ -20,7 +20,7 @@ func TestGetRepos(t *testing.T) {
fmt.Println("Testing Repos Get API")
//-------------------case 1 : response code = 200------------------------//
fmt.Println("case 1 : response code = 200")
httpStatusCode, err = apiTest.GetRepos(*admin, projectID)
httpStatusCode, err = apiTest.GetRepos(*admin, projectID, "true")
if err != nil {
t.Error("Error whihle get repos by projectID", err.Error())
t.Log(err)
@ -30,7 +30,7 @@ func TestGetRepos(t *testing.T) {
//-------------------case 2 : response code = 400------------------------//
fmt.Println("case 2 : response code = 409,invalid project_id")
projectID = "ccc"
httpStatusCode, err = apiTest.GetRepos(*admin, projectID)
httpStatusCode, err = apiTest.GetRepos(*admin, projectID, "0")
if err != nil {
t.Error("Error whihle get repos by projectID", err.Error())
t.Log(err)
@ -40,7 +40,7 @@ func TestGetRepos(t *testing.T) {
//-------------------case 3 : response code = 404------------------------//
fmt.Println("case 3 : response code = 404:project not found")
projectID = "111"
httpStatusCode, err = apiTest.GetRepos(*admin, projectID)
httpStatusCode, err = apiTest.GetRepos(*admin, projectID, "0")
if err != nil {
t.Error("Error whihle get repos by projectID", err.Error())
t.Log(err)

View File

@ -20,12 +20,12 @@ import (
"sort"
"strings"
"github.com/vmware/harbor/src/common/api"
"github.com/vmware/harbor/src/common/dao"
"github.com/vmware/harbor/src/common/models"
"github.com/vmware/harbor/src/ui/service/cache"
"github.com/vmware/harbor/src/common/utils"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/common/api"
"github.com/vmware/harbor/src/ui/config"
)
// SearchAPI handles requesst to /api/search
@ -34,7 +34,7 @@ type SearchAPI struct {
}
type searchResult struct {
Project []map[string]interface{} `json:"project"`
Project []models.Project `json:"project"`
Repository []map[string]interface{} `json:"repository"`
}
@ -71,58 +71,104 @@ func (s *SearchAPI) Get() {
projectSorter := &models.ProjectSorter{Projects: projects}
sort.Sort(projectSorter)
projectResult := []map[string]interface{}{}
projectResult := []models.Project{}
for _, p := range projects {
match := true
if len(keyword) > 0 && !strings.Contains(p.Name, keyword) {
match = false
}
if match {
entry := make(map[string]interface{})
entry["id"] = p.ProjectID
entry["name"] = p.Name
entry["public"] = p.Public
projectResult = append(projectResult, entry)
if userID != dao.NonExistUserID {
if isSysAdmin {
p.Role = models.PROJECTADMIN
}
if p.Role == models.PROJECTADMIN {
p.Togglable = true
}
}
repos, err := dao.GetRepositoryByProjectName(p.Name)
if err != nil {
log.Errorf("failed to get repositories of project %s: %v", p.Name, err)
s.CustomAbort(http.StatusInternalServerError, "")
}
p.RepoCount = len(repos)
projectResult = append(projectResult, p)
}
}
repositories, err := cache.GetRepoFromCache()
repositoryResult, err := filterRepositories(projects, keyword)
if err != nil {
log.Errorf("failed to list repositories: %v", err)
log.Errorf("failed to filter repositories: %v", err)
s.CustomAbort(http.StatusInternalServerError, "")
}
sort.Strings(repositories)
repositoryResult := filterRepositories(repositories, projects, keyword)
result := &searchResult{Project: projectResult, Repository: repositoryResult}
s.Data["json"] = result
s.ServeJSON()
}
func filterRepositories(repositories []string, projects []models.Project, keyword string) []map[string]interface{} {
func filterRepositories(projects []models.Project, keyword string) (
[]map[string]interface{}, error) {
repositories, err := dao.GetAllRepositories()
if err != nil {
return nil, err
}
i, j := 0, 0
result := []map[string]interface{}{}
for i < len(repositories) && j < len(projects) {
r := repositories[i]
p, _ := utils.ParseRepository(r)
p, _ := utils.ParseRepository(r.Name)
d := strings.Compare(p, projects[j].Name)
if d < 0 {
i++
continue
} else if d == 0 {
i++
if len(keyword) != 0 && !strings.Contains(r, keyword) {
if len(keyword) != 0 && !strings.Contains(r.Name, keyword) {
continue
}
entry := make(map[string]interface{})
entry["repository_name"] = r
entry["repository_name"] = r.Name
entry["project_name"] = projects[j].Name
entry["project_id"] = projects[j].ProjectID
entry["project_public"] = projects[j].Public
entry["pull_count"] = r.PullCount
tags, err := getTags(r.Name)
if err != nil {
return nil, err
}
entry["tags_count"] = len(tags)
result = append(result, entry)
} else {
j++
}
}
return result
return result, nil
}
func getTags(repository string) ([]string, error) {
url, err := config.RegistryURL()
if err != nil {
return nil, err
}
client, err := NewRepositoryClient(url, true,
"admin", repository, "repository", repository, "pull")
if err != nil {
return nil, err
}
tags, err := listTag(client)
if err != nil {
return nil, err
}
return tags, nil
}

View File

@ -22,9 +22,9 @@ func TestSearch(t *testing.T) {
t.Log(err)
} else {
assert.Equal(int(200), httpStatusCode, "httpStatusCode should be 200")
assert.Equal(int64(1), result.Projects[0].Id, "Project id should be equal")
assert.Equal(int64(1), result.Projects[0].ProjectID, "Project id should be equal")
assert.Equal("library", result.Projects[0].Name, "Project name should be library")
assert.Equal(int32(1), result.Projects[0].Public, "Project public status should be 1 (true)")
assert.Equal(1, result.Projects[0].Public, "Project public status should be 1 (true)")
}
//--------case 2 : Response Code = 200, sysAdmin and search repo--------//

View File

@ -29,9 +29,9 @@ import (
"github.com/vmware/harbor/src/common/utils"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/common/utils/registry"
"github.com/vmware/harbor/src/common/utils/registry/auth"
registry_error "github.com/vmware/harbor/src/common/utils/registry/error"
"github.com/vmware/harbor/src/ui/config"
"github.com/vmware/harbor/src/ui/service/cache"
)
func checkProjectPermission(userID int, projectID int64) bool {
@ -352,7 +352,7 @@ func diffRepos(reposInRegistry []string, reposInDB []string) ([]string, []string
if err != nil {
return needsAdd, needsDel, err
}
client, err := cache.NewRepositoryClient(endpoint, true,
client, err := NewRepositoryClient(endpoint, true,
"admin", repoInR, "repository", repoInR)
if err != nil {
return needsAdd, needsDel, err
@ -377,7 +377,7 @@ func diffRepos(reposInRegistry []string, reposInDB []string) ([]string, []string
if err != nil {
return needsAdd, needsDel, err
}
client, err := cache.NewRepositoryClient(endpoint, true,
client, err := NewRepositoryClient(endpoint, true,
"admin", repoInR, "repository", repoInR)
if err != nil {
return needsAdd, needsDel, err
@ -440,7 +440,7 @@ func initRegistryClient() (r *registry.Registry, err error) {
return nil, err
}
registryClient, err := cache.NewRegistryClient(endpoint, true, "admin",
registryClient, err := NewRegistryClient(endpoint, true, "admin",
"registry", "catalog", "*")
if err != nil {
return nil, err
@ -485,10 +485,6 @@ func getReposByProject(name string, keyword ...string) ([]string, error) {
return repositories, nil
}
func getAllRepos() ([]string, error) {
return cache.GetRepoFromCache()
}
func repositoryExist(name string, client *registry.Repository) (bool, error) {
tags, err := client.ListTag()
if err != nil {
@ -499,3 +495,38 @@ func repositoryExist(name string, client *registry.Repository) (bool, error) {
}
return len(tags) != 0, nil
}
// NewRegistryClient ...
func NewRegistryClient(endpoint string, insecure bool, username, scopeType, scopeName string,
scopeActions ...string) (*registry.Registry, error) {
authorizer := auth.NewRegistryUsernameTokenAuthorizer(username, scopeType, scopeName, scopeActions...)
store, err := auth.NewAuthorizerStore(endpoint, insecure, authorizer)
if err != nil {
return nil, err
}
client, err := registry.NewRegistryWithModifiers(endpoint, insecure, store)
if err != nil {
return nil, err
}
return client, nil
}
// NewRepositoryClient ...
func NewRepositoryClient(endpoint string, insecure bool, username, repository, scopeType, scopeName string,
scopeActions ...string) (*registry.Repository, error) {
authorizer := auth.NewRegistryUsernameTokenAuthorizer(username, scopeType, scopeName, scopeActions...)
store, err := auth.NewAuthorizerStore(endpoint, insecure, authorizer)
if err != nil {
return nil, err
}
client, err := registry.NewRepositoryWithModifiers(repository, endpoint, insecure, store)
if err != nil {
return nil, err
}
return client, nil
}

View File

@ -74,6 +74,7 @@ func initRouters() {
beego.Router("/api/repositories", &api.RepositoryAPI{})
beego.Router("/api/repositories/tags", &api.RepositoryAPI{}, "get:GetTags")
beego.Router("/api/repositories/manifests", &api.RepositoryAPI{}, "get:GetManifests")
beego.Router("/api/repositories/signatures", &api.RepositoryAPI{}, "get:GetSignatures")
beego.Router("/api/jobs/replication/", &api.RepJobAPI{}, "get:List")
beego.Router("/api/jobs/replication/:id([0-9]+)", &api.RepJobAPI{})
beego.Router("/api/jobs/replication/:id([0-9]+)/log", &api.RepJobAPI{}, "get:GetLog")
@ -96,6 +97,7 @@ func initRouters() {
beego.Router("/api/ldap/ping", &api.LdapAPI{}, "post:Ping")
beego.Router("/api/ldap/users/search", &api.LdapAPI{}, "post:Search")
beego.Router("/api/ldap/users/import", &api.LdapAPI{}, "post:ImportUser")
beego.Router("/api/email/ping", &api.EmailAPI{}, "post:Ping")
//external service that hosted on harbor process:
beego.Router("/service/notifications", &service.NotificationHandler{})

View File

@ -1,119 +0,0 @@
/*
Copyright (c) 2016 VMware, Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cache
import (
"time"
"github.com/vmware/harbor/src/common/dao"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/common/utils/registry"
"github.com/vmware/harbor/src/common/utils/registry/auth"
"github.com/astaxie/beego/cache"
)
var (
// Cache is the global cache in system.
Cache cache.Cache
)
const catalogKey string = "catalog"
func init() {
var err error
Cache, err = cache.NewCache("memory", `{"interval":720}`)
if err != nil {
log.Errorf("Failed to initialize cache, error:%v", err)
}
}
// RefreshCatalogCache calls registry's API to get repository list and write it to cache.
func RefreshCatalogCache() error {
log.Debug("refreshing catalog cache...")
repos, err := getAllRepositories()
if err != nil {
return err
}
Cache.Put(catalogKey, repos, 600*time.Second)
return nil
}
// GetRepoFromCache get repository list from cache, it refreshes the cache if it's empty.
func GetRepoFromCache() ([]string, error) {
result := Cache.Get(catalogKey)
if result == nil {
err := RefreshCatalogCache()
if err != nil {
return nil, err
}
cached := Cache.Get(catalogKey)
if cached != nil {
return cached.([]string), nil
}
return nil, nil
}
return result.([]string), nil
}
func getAllRepositories() ([]string, error) {
var repos []string
rs, err := dao.GetAllRepositories()
if err != nil {
return repos, err
}
for _, e := range rs {
repos = append(repos, e.Name)
}
return repos, nil
}
// NewRegistryClient ...
func NewRegistryClient(endpoint string, insecure bool, username, scopeType, scopeName string,
scopeActions ...string) (*registry.Registry, error) {
authorizer := auth.NewUsernameTokenAuthorizer(username, scopeType, scopeName, scopeActions...)
store, err := auth.NewAuthorizerStore(endpoint, insecure, authorizer)
if err != nil {
return nil, err
}
client, err := registry.NewRegistryWithModifiers(endpoint, insecure, store)
if err != nil {
return nil, err
}
return client, nil
}
// NewRepositoryClient ...
func NewRepositoryClient(endpoint string, insecure bool, username, repository, scopeType, scopeName string,
scopeActions ...string) (*registry.Repository, error) {
authorizer := auth.NewUsernameTokenAuthorizer(username, scopeType, scopeName, scopeActions...)
store, err := auth.NewAuthorizerStore(endpoint, insecure, authorizer)
if err != nil {
return nil, err
}
client, err := registry.NewRepositoryWithModifiers(repository, endpoint, insecure, store)
if err != nil {
return nil, err
}
return client, nil
}

View File

@ -1,9 +0,0 @@
package cache
import (
"testing"
)
func TestMain(t *testing.T) {
}

View File

@ -25,7 +25,6 @@ import (
"github.com/vmware/harbor/src/common/utils"
"github.com/vmware/harbor/src/common/utils/log"
"github.com/vmware/harbor/src/ui/api"
"github.com/vmware/harbor/src/ui/service/cache"
"github.com/astaxie/beego"
)
@ -82,9 +81,6 @@ func (n *NotificationHandler) Post() {
if err := dao.AddRepository(repoRecord); err != nil {
log.Errorf("Error happens when adding repository: %v", err)
}
if err := cache.RefreshCatalogCache(); err != nil {
log.Errorf("failed to refresh cache: %v", err)
}
}()
go api.TriggerReplicationByRepository(repository, []string{tag}, models.RepOpTransfer)
}

View File

@ -74,25 +74,49 @@ func GetResourceActions(scopes []string) []*token.ResourceActions {
return res
}
// GenTokenForUI is for the UI process to call, so it won't establish a https connection from UI to proxy.
func GenTokenForUI(username string, service string, scopes []string) (string, int, *time.Time, error) {
//filterAccess iterate a list of resource actions and try to use the filter that matches the resource type to filter the actions.
func filterAccess(access []*token.ResourceActions, u userInfo, filters map[string]accessFilter) error {
var err error
for _, a := range access {
f, ok := filters[a.Type]
if !ok {
a.Actions = []string{}
log.Warningf("No filter found for access type: %s, skip filter, the access of resource '%s' will be set empty.", a.Type, a.Name)
continue
}
err = f.filter(u, a)
log.Debugf("user: %s, access: %v", u.name, a)
if err != nil {
return err
}
}
return nil
}
//RegistryTokenForUI calls genTokenForUI to get raw token for registry
func RegistryTokenForUI(username string, service string, scopes []string) (string, int, *time.Time, error) {
return genTokenForUI(username, service, scopes, registryFilterMap)
}
//NotaryTokenForUI calls genTokenForUI to get raw token for notary
func NotaryTokenForUI(username string, service string, scopes []string) (string, int, *time.Time, error) {
return genTokenForUI(username, service, scopes, notaryFilterMap)
}
// genTokenForUI is for the UI process to call, so it won't establish a https connection from UI to proxy.
func genTokenForUI(username string, service string, scopes []string, filters map[string]accessFilter) (string, int, *time.Time, error) {
isAdmin, err := dao.IsAdminRole(username)
if err != nil {
return "", 0, nil, err
}
f := &repositoryFilter{
parser: &basicParser{},
}
u := userInfo{
name: username,
allPerm: isAdmin,
}
access := GetResourceActions(scopes)
for _, a := range access {
err = f.filter(u, a)
if err != nil {
return "", 0, nil, err
}
err = filterAccess(access, u, filters)
if err != nil {
return "", 0, nil, err
}
return MakeRawToken(username, service, access)
}

View File

@ -26,6 +26,8 @@ import (
)
var creatorMap map[string]Creator
var registryFilterMap map[string]accessFilter
var notaryFilterMap map[string]accessFilter
const (
notary = "harbor-notary"
@ -35,22 +37,29 @@ const (
//InitCreators initialize the token creators for different services
func InitCreators() {
creatorMap = make(map[string]Creator)
registryFilterMap = map[string]accessFilter{
"repository": &repositoryFilter{
parser: &basicParser{},
},
"registry": &registryFilter{},
}
ext, err := config.ExtEndpoint()
if err != nil {
log.Warningf("Failed to get ext enpoint, err: %v, the token service will not be functional with notary requests", err)
} else {
notaryFilterMap = map[string]accessFilter{
"repository": &repositoryFilter{
parser: &endpointParser{
endpoint: strings.Split(ext, "//")[1],
},
},
}
creatorMap[notary] = &generalCreator{
validators: []ReqValidator{
&basicAuthValidator{},
},
service: notary,
filterMap: map[string]accessFilter{
"repository": &repositoryFilter{
parser: &endpointParser{
endpoint: strings.Split(ext, "//")[1],
},
},
},
service: notary,
filterMap: notaryFilterMap,
}
}
@ -59,16 +68,8 @@ func InitCreators() {
&secretValidator{config.JobserviceSecret()},
&basicAuthValidator{},
},
service: registry,
filterMap: map[string]accessFilter{
"repository": &repositoryFilter{
//Workaround, had to use same service for both notary and registry
parser: &endpointParser{
endpoint: ext,
},
},
"registry": &registryFilter{},
},
service: registry,
filterMap: registryFilterMap,
}
}
@ -102,15 +103,10 @@ func (e endpointParser) parse(s string) (*image, error) {
if len(repo) < 2 {
return nil, fmt.Errorf("Unable to parse image from string: %s", s)
}
//Workaround, need to use endpoint Parser to handle both cases.
if strings.ContainsRune(repo[0], '.') {
if repo[0] != e.endpoint {
log.Warningf("Mismatch endpoint from string: %s, expected endpoint: %s, fallback to basic parser", s, e.endpoint)
return parseImg(s)
}
return parseImg(repo[1])
if repo[0] != e.endpoint {
return nil, fmt.Errorf("Mismatch endpoint from string: %s, expected endpoint: %s", s, e.endpoint)
}
return parseImg(s)
return parseImg(repo[1])
}
//build Image accepts a string like library/ubuntu:14.04 and build a image struct
@ -153,7 +149,6 @@ type repositoryFilter struct {
func (rep repositoryFilter) filter(user userInfo, a *token.ResourceActions) error {
//clear action list to assign to new acess element after perm check.
a.Actions = []string{}
img, err := rep.parser.parse(a.Name)
if err != nil {
return err
@ -224,16 +219,9 @@ func (g generalCreator) Create(r *http.Request) (*tokenJSON, error) {
user = &userInfo{}
}
access := GetResourceActions(scopes)
for _, a := range access {
f, ok := g.filterMap[a.Type]
if !ok {
log.Warningf("No filter found for access type: %s, skip.", a.Type)
continue
}
err = f.filter(*user, a)
if err != nil {
return nil, err
}
err = filterAccess(access, *user, g.filterMap)
if err != nil {
return nil, err
}
return makeToken(user.name, g.service, access)
}

View File

@ -31,6 +31,7 @@ func TestMain(m *testing.M) {
if err := config.Init(); err != nil {
panic(err)
}
InitCreators()
result := m.Run()
if result != 0 {
os.Exit(result)
@ -169,9 +170,8 @@ func TestEndpointParser(t *testing.T) {
}
testList := []parserTestRec{parserTestRec{"10.117.4.142:5000/library/ubuntu:14.04", image{"library", "ubuntu", "14.04"}, false},
parserTestRec{"myimage:14.04", image{}, true},
//Test the temp workaround
parserTestRec{"10.117.4.142:80/library/myimage:14.04", image{"10.117.4.142:80", "library/myimage", "14.04"}, false},
parserTestRec{"library/myimage:14.04", image{"library", "myimage", "14.04"}, false},
parserTestRec{"10.117.4.142:80/library/myimage:14.04", image{}, true},
parserTestRec{"library/myimage:14.04", image{}, true},
parserTestRec{"10.117.4.142:5000/myimage:14.04", image{}, true},
parserTestRec{"10.117.4.142:5000/org/team/img", image{"org", "team/img", ""}, false},
}
@ -185,3 +185,28 @@ func TestEndpointParser(t *testing.T) {
}
}
}
func TestFilterAccess(t *testing.T) {
//TODO put initial data in DB to verify repository filter.
var err error
s := []string{"registry:catalog:*"}
a1 := GetResourceActions(s)
a2 := GetResourceActions(s)
u := userInfo{"jack", false}
ra1 := token.ResourceActions{
Type: "registry",
Name: "catalog",
Actions: []string{"*"},
}
ra2 := token.ResourceActions{
Type: "registry",
Name: "catalog",
Actions: []string{},
}
err = filterAccess(a1, u, registryFilterMap)
assert.Nil(t, err, "Unexpected error: %v", err)
assert.Equal(t, ra1, *a1[0], "Mismatch after registry filter Map")
err = filterAccess(a2, u, notaryFilterMap)
assert.Nil(t, err, "Unexpected error: %v", err)
assert.Equal(t, ra2, *a2[0], "Mismatch after notary filter Map")
}

View File

@ -1,20 +0,0 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = 0
trim_trailing_whitespace = true
# Indentation override
#[lib/**.js]
#[{package.json,.travis.yml}]
#[**/**.js]

View File

@ -1,9 +0,0 @@
coverage/
dist/
html-report/
node_modules/
typings/
**/*npm-debug.log.*
**/*yarn-error.log.*
.idea/
.DS_Store

View File

@ -1,10 +0,0 @@
language: node_js
node_js:
- "6.9"
addons:
apt:
sources:
- ubuntu-toolchain-r-test
packages:
- g++-4.8

View File

@ -4,19 +4,19 @@
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular/cli'],
frameworks: ['jasmine', 'angular-cli'],
plugins: [
require('karma-jasmine'),
require('karma-phantomjs-launcher'),
require('karma-mocha-reporter'),
require('karma-remap-istanbul'),
require('@angular/cli/plugins/karma')
require('angular-cli/plugins/karma')
],
files: [
{pattern: './src/test.ts', watched: false}
],
preprocessors: {
'./src/test.ts': ['@angular/cli']
'./src/test.ts': ['angular-cli']
},
mime: {
'text/x-typescript': ['ts', 'tsx']

View File

@ -4,7 +4,7 @@
"description": "Angular-CLI starter for a Clarity project",
"angular-cli": {},
"scripts": {
"start": "ng serve",
"start": "ng serve --host 0.0.0.0",
"lint": "tslint \"src/**/*.ts\"",
"test": "ng test --single-run",
"pree2e": "webdriver-manager update",
@ -12,7 +12,6 @@
},
"private": true,
"dependencies": {
"@angular/cli": "^1.0.0-beta.30",
"@angular/common": "^2.4.1",
"@angular/compiler": "^2.4.1",
"@angular/core": "^2.4.1",
@ -21,11 +20,15 @@
"@angular/platform-browser": "^2.4.1",
"@angular/platform-browser-dynamic": "^2.4.1",
"@angular/router": "^3.4.1",
"@ngx-translate/core": "^6.0.0",
"@ngx-translate/http-loader": "0.0.3",
"@webcomponents/custom-elements": "1.0.0-alpha.3",
"angular2-cookie": "^1.2.6",
"clarity-angular": "^0.8.0",
"clarity-icons": "^0.8.0",
"clarity-ui": "^0.8.0",
"core-js": "^2.4.1",
"fs": "0.0.1-security",
"mutationobserver-shim": "^0.3.2",
"rxjs": "^5.0.1",
"ts-helpers": "^1.1.1",
@ -37,6 +40,7 @@
"@types/core-js": "^0.9.34",
"@types/jasmine": "^2.2.30",
"@types/node": "^6.0.42",
"angular-cli": "^1.0.0-beta.24",
"bootstrap": "4.0.0-alpha.5",
"codelyzer": "~1.0.0-beta.3",
"enhanced-resolve": "^3.0.0",

View File

@ -1,55 +1,48 @@
<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="staticBackdrop" [clrModalSize]="'lg'">
<h3 class="modal-title">User Profile</h3>
<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="staticBackdrop">
<h3 class="modal-title">{{'PROFILE.TITLE' | translate}}</h3>
<div class="modal-body" style="overflow-y: hidden;">
<form #accountSettingsFrom="ngForm" class="form">
<section class="form-block">
<div class="form-group">
<label for="account_settings_username" class="col-md-4">Username</label>
<input type="text" name="account_settings_username" [(ngModel)]="account.username" disabled id="account_settings_username" size="51">
<label for="account_settings_username" class="col-md-4">{{'PROFILE.USER_NAME' | translate}}</label>
<input type="text" name="account_settings_username" [(ngModel)]="account.username" disabled id="account_settings_username" size="31">
</div>
<div class="form-group">
<label for="account_settings_email" class="col-md-4 required">Email</label>
<label for="account_settings_email" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-right" [class.invalid]="eamilInput.invalid && (eamilInput.dirty || eamilInput.touched)">
<label for="account_settings_email" class="col-md-4 required">{{'PROFILE.EMAIL' | translate}}</label>
<label for="account_settings_email" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-left" [class.invalid]="eamilInput.invalid && (eamilInput.dirty || eamilInput.touched)">
<input name="account_settings_email" type="text" #eamilInput="ngModel" [(ngModel)]="account.email"
required
pattern='^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$' id="account_settings_email" size="48">
pattern='^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$' id="account_settings_email" size="28">
<span class="tooltip-content">
Email should be a valid email address like name@example.com
{{'TOOLTIP.EMAIL' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="account_settings_full_name" class="col-md-4 required">Full name</label>
<label for="account_settings_email" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-right" [class.invalid]="fullNameInput.invalid && (fullNameInput.dirty || fullNameInput.touched)">
<input type="text" name="account_settings_full_name" #fullNameInput="ngModel" [(ngModel)]="account.realname" required maxLengthExt="20" id="account_settings_full_name" size="48">
<label for="account_settings_full_name" class="col-md-4 required">{{'PROFILE.FULL_NAME' | translate}}</label>
<label for="account_settings_full_name" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-left" [class.invalid]="fullNameInput.invalid && (fullNameInput.dirty || fullNameInput.touched)">
<input type="text" name="account_settings_full_name" #fullNameInput="ngModel" [(ngModel)]="account.realname" required maxLengthExt="20" id="account_settings_full_name" size="28">
<span class="tooltip-content">
Max length of full name is 20
{{'TOOLTIP.FULL_NAME' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="account_settings_comments" class="col-md-4">Comments</label>
<label for="account_settings_comments" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-right" [class.invalid]="commentInput.invalid && (commentInput.dirty || commentInput.touched)">
<input type="text" #commentInput="ngModel" maxLengthExt="20" name="account_settings_comments" [(ngModel)]="account.comment" id="account_settings_comments" size="48">
<label for="account_settings_comments" class="col-md-4">{{'PROFILE.COMMENT' | translate}}</label>
<label for="account_settings_comments" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-left" [class.invalid]="commentInput.invalid && (commentInput.dirty || commentInput.touched)">
<input type="text" #commentInput="ngModel" maxLengthExt="20" name="account_settings_comments" [(ngModel)]="account.comment" id="account_settings_comments" size="28">
<span class="tooltip-content">
Length of comment should be less than 20
{{'TOOLTIP.COMMENT' | translate}}
</span>
</label>
</div>
</section>
</form>
<div style="height: 30px;"></div>
<clr-alert [clrAlertType]="'alert-danger'" [clrAlertClosable]="true" [(clrAlertClosed)]="alertClose">
<div class="alert-item">
<span class="alert-text">
{{errorMessage}}
</span>
</div>
</clr-alert>
<inline-alert (confirmEvt)="confirmCancel($event)"></inline-alert>
</div>
<div class="modal-footer">
<span class="spinner spinner-inline" style="top:8px;" [hidden]="showProgress === false"></span>
<button type="button" class="btn btn-outline" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" [disabled]="!isValid || showProgress" (click)="submit()">Ok</button>
<button type="button" class="btn btn-outline" (click)="close()">{{'BUTTON.CANCEL' | translate}}</button>
<button type="button" class="btn btn-primary" [disabled]="!isValid || showProgress" (click)="submit()">{{'BUTTON.OK' | translate}}</button>
</div>
</clr-modal>

View File

@ -3,6 +3,10 @@ import { NgForm } from '@angular/forms';
import { SessionUser } from '../../shared/session-user';
import { SessionService } from '../../shared/session.service';
import { MessageService } from '../../global-message/message.service';
import { AlertType, httpStatusCode } from '../../shared/shared.const';
import { errorHandler, accessErrorHandler } from '../../shared/shared.utils';
import { InlineAlertComponent } from '../../shared/inline-alert/inline-alert.component';
@Component({
selector: "account-settings-modal",
@ -13,43 +17,52 @@ export class AccountSettingsModalComponent implements OnInit, AfterViewChecked {
opened: boolean = false;
staticBackdrop: boolean = true;
account: SessionUser;
error: any;
alertClose: boolean = true;
error: any = null;
originalStaticData: SessionUser;
private isOnCalling: boolean = false;
private formValueChanged: boolean = false;
accountFormRef: NgForm;
@ViewChild("accountSettingsFrom") accountForm: NgForm;
@ViewChild(InlineAlertComponent)
private inlineAlert: InlineAlertComponent;
constructor(private session: SessionService) { }
constructor(
private session: SessionService,
private msgService: MessageService) { }
ngOnInit(): void {
//Value copy
this.account = Object.assign({}, this.session.getCurrentUser());
}
private isUserDataChange(): boolean {
if (!this.originalStaticData || !this.account) {
return false;
}
for (var prop in this.originalStaticData) {
if (this.originalStaticData[prop]) {
if (this.account[prop]) {
if (this.originalStaticData[prop] != this.account[prop]) {
return true;
}
}
}
}
return false;
}
public get isValid(): boolean {
return this.accountForm && this.accountForm.valid;
return this.accountForm && this.accountForm.valid && this.error === null;
}
public get showProgress(): boolean {
return this.isOnCalling;
}
public get errorMessage(): string {
if(this.error){
if(this.error.message){
return this.error.message;
}else{
if(this.error._body){
return this.error._body;
}
}
}
return "";
}
ngAfterViewChecked(): void {
if (this.accountFormRef != this.accountForm) {
this.accountFormRef = this.accountForm;
@ -59,12 +72,15 @@ export class AccountSettingsModalComponent implements OnInit, AfterViewChecked {
this.error = null;
}
this.formValueChanged = true;
this.inlineAlert.close();
});
}
}
}
open() {
//Keep the initial data for future diff
this.originalStaticData = Object.assign({}, this.session.getCurrentUser());
this.account = Object.assign({}, this.session.getCurrentUser());
this.formValueChanged = false;
@ -72,7 +88,18 @@ export class AccountSettingsModalComponent implements OnInit, AfterViewChecked {
}
close() {
this.opened = false;
if (this.formValueChanged) {
if (!this.isUserDataChange()) {
this.opened = false;
} else {
//Need user confirmation
this.inlineAlert.showInlineConfirmation({
message: "ALERT.FORM_CHANGE_CONFIRMATION"
});
}
} else {
this.opened = false;
}
}
submit() {
@ -92,12 +119,22 @@ export class AccountSettingsModalComponent implements OnInit, AfterViewChecked {
.then(() => {
this.isOnCalling = false;
this.close();
this.msgService.announceMessage(200, "PROFILE.SAVE_SUCCESS", AlertType.SUCCESS);
})
.catch(error => {
this.isOnCalling = false;
this.error = error;
this.alertClose = false;
if(accessErrorHandler(error, this.msgService)){
this.opened = false;
}else{
this.inlineAlert.showInlineError(error);
}
});
}
confirmCancel(): void {
this.inlineAlert.close();
this.opened = false;
}
}

View File

@ -6,6 +6,9 @@ import { SignInComponent } from './sign-in/sign-in.component';
import { PasswordSettingComponent } from './password/password-setting.component';
import { AccountSettingsModalComponent } from './account-settings/account-settings-modal.component';
import { SharedModule } from '../shared/shared.module';
import { SignUpComponent } from './sign-up/sign-up.component';
import { ForgotPasswordComponent } from './password/forgot-password.component';
import { ResetPasswordComponent } from './password/reset-password.component';
import { PasswordSettingService } from './password/password-setting.service';
@ -15,9 +18,19 @@ import { PasswordSettingService } from './password/password-setting.service';
RouterModule,
SharedModule
],
declarations: [SignInComponent, PasswordSettingComponent, AccountSettingsModalComponent],
exports: [SignInComponent, PasswordSettingComponent, AccountSettingsModalComponent],
declarations: [
SignInComponent,
PasswordSettingComponent,
AccountSettingsModalComponent,
SignUpComponent,
ForgotPasswordComponent,
ResetPasswordComponent],
exports: [
SignInComponent,
PasswordSettingComponent,
AccountSettingsModalComponent,
ResetPasswordComponent],
providers: [PasswordSettingService]
})
export class AccountModule { }

View File

@ -0,0 +1,32 @@
<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="true">
<h3 class="modal-title">{{'RESET_PWD.TITLE' | translate}}</h3>
<label class="modal-title reset-modal-title-override">{{'RESET_PWD.CAPTION' | translate}}</label>
<div class="modal-body" style="overflow-y: hidden;">
<form #forgotPasswordFrom="ngForm" class="form">
<section class="form-block">
<div class="form-group">
<label for="reset_pwd_email" class="col-md-4 required">{{'RESET_PWD.EMAIL' | translate}}</label>
<label for="reset_pwd_email" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-left" [class.invalid]="validationState === false">
<input name="reset_pwd_email" type="text" #eamilInput="ngModel" [(ngModel)]="email"
required
pattern='^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$'
id="reset_pwd_email"
size="36"
(input)="handleValidation(true)"
(focusout)="handleValidation(false)">
<span class="tooltip-content">
{{'TOOLTIP.EMAIL' | translate}}
</span>
</label>
</div>
</section>
</form>
<inline-alert></inline-alert>
<div style="height: 30px;"></div>
</div>
<div class="modal-footer">
<span class="spinner spinner-inline" style="top:8px;" [hidden]="showProgress === false"></span>
<button type="button" class="btn btn-outline" (click)="close()">{{'BUTTON.CANCEL' | translate}}</button>
<button type="button" class="btn btn-primary" [disabled]="!isValid || showProgress" (click)="send()">{{'BUTTON.SEND' | translate}}</button>
</div>
</clr-modal>

View File

@ -1,10 +1,78 @@
import { Component } from '@angular/core';
import { Component, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { NgForm } from '@angular/forms';
import { PasswordSettingService } from './password-setting.service';
import { InlineAlertComponent } from '../../shared/inline-alert/inline-alert.component';
@Component({
selector: 'forgot-password',
templateUrl: "forgot-password.component.html"
templateUrl: "forgot-password.component.html",
styleUrls: ['password.component.css']
})
export class ForgotPasswordComponent {
// constructor(private router: Router){}
opened: boolean = false;
private onGoing: boolean = false;
private email: string = "";
private validationState: boolean = true;
private forceValid: boolean = true;
@ViewChild("forgotPasswordFrom") forgotPwdForm: NgForm;
@ViewChild(InlineAlertComponent)
private inlineAlert: InlineAlertComponent;
constructor(private pwdService: PasswordSettingService) { }
public get showProgress(): boolean {
return this.onGoing;
}
public get isValid(): boolean {
return this.forgotPwdForm && this.forgotPwdForm.valid && this.forceValid;
}
public open(): void {
this.opened = true;
this.validationState = true;
this.forceValid = true;
this.forgotPwdForm.resetForm();
}
public close(): void {
this.opened = false;
}
public send(): void {
//Double confirm to avoid improper situations
if (!this.email) {
return;
}
if (!this.isValid) {
return;
}
this.onGoing = true;
this.pwdService.sendResetPasswordMail(this.email)
.then(response => {
this.onGoing = false;
this.forceValid = false;//diable the send button
this.inlineAlert.showInlineSuccess({
message: "RESET_PWD.SUCCESS"
});
})
.catch(error => {
this.onGoing = false;
this.inlineAlert.showInlineError(error);
})
}
public handleValidation(flag: boolean): void {
if (flag) {
this.validationState = true;
} else {
this.validationState = this.isValid;
}
}
}

View File

@ -1,55 +1,56 @@
<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="true">
<h3 class="modal-title">Change Password</h3>
<h3 class="modal-title">{{'CHANGE_PWD.TITLE' | translate}}</h3>
<div class="modal-body" style="min-height: 250px; overflow-y: hidden;">
<form #changepwdForm="ngForm" class="form">
<section class="form-block">
<div class="form-group">
<label for="oldPassword">Current Password</label>
<label for="oldPassword">{{'CHANGE_PWD.CURRENT_PWD' | translate}}</label>
<label for="oldPassword" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-left" [class.invalid]="oldPassInput.invalid && (oldPassInput.dirty || oldPassInput.touched)">
<input type="password" id="oldPassword" placeholder="Enter current password"
<input type="password" id="oldPassword" placeholder='{{"PLACEHOLDER.CURRENT_PWD" | translate}}'
required
name="oldPassword"
[(ngModel)]="oldPwd"
#oldPassInput="ngModel" size="25">
<span class="tooltip-content">
Current password is Required.
{{'TOOLTIP.CURRENT_PWD' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<label for="newPassword" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-left" [class.invalid]="newPassInput.invalid && (newPassInput.dirty || newPassInput.touched)">
<input type="password" id="newPassword" placeholder="Enter new password"
<label for="newPassword">{{'CHANGE_PWD.NEW_PWD' | translate}}</label>
<label for="newPassword" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-left" [class.invalid]="newPassInput.invalid && (newPassInput.dirty || newPassInput.touched)">
<input type="password" id="newPassword" placeholder='{{"PLACEHOLDER.NEW_PWD" | translate}}'
required
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{7,}$"
name="newPassword"
[(ngModel)]="newPwd"
#newPassInput="ngModel" size="25">
<span class="tooltip-content">
Password should be at least 7 characters with 1 uppercase, 1 lowercase letter and 1 number
{{'TOOLTIP.PASSWORD' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="reNewPassword">Confirm Password</label>
<label for="reNewPassword" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-left" [class.invalid]="(reNewPassInput.invalid && (reNewPassInput.dirty || reNewPassInput.touched)) || (!newPassInput.invalid && reNewPassInput.value != newPassInput.value)">
<input type="password" id="reNewPassword" placeholder="Confirm new password"
<label for="reNewPassword">{{'CHANGE_PWD.CONFIRM_PWD' | translate}}</label>
<label for="reNewPassword" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-left" [class.invalid]="(reNewPassInput.invalid && (reNewPassInput.dirty || reNewPassInput.touched)) || (!newPassInput.invalid && reNewPassInput.value != newPassInput.value)">
<input type="password" id="reNewPassword" placeholder='{{"PLACEHOLDER.CONFIRM_PWD" | translate}}'
required
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{7,}$"
name="reNewPassword"
[(ngModel)]="reNewPwd"
#reNewPassInput="ngModel" size="25">
<span class="tooltip-content">
Password should be at least 7 characters with 1 uppercase, 1 lowercase letter and 1 number and same with new password
{{'TOOLTIP.CONFIRM_PWD' | translate}}
</span>
</label>
</div>
</section>
<inline-alert (confirmEvt)="confirmCancel($event)"></inline-alert>
</form>
</div>
<div class="modal-footer">
<span class="spinner spinner-inline" style="top:8px;" [hidden]="showProgress === false"></span>
<button type="button" class="btn btn-outline" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" [disabled]="!isValid || showProgress" (click)="doOk()">Ok</button>
<button type="button" class="btn btn-outline" (click)="close()">{{'BUTTON.CANCEL' | translate}}</button>
<button type="button" class="btn btn-primary" [disabled]="!isValid || showProgress" (click)="doOk()">{{'BUTTON.OK' | translate}}</button>
</div>
</clr-modal>

View File

@ -4,6 +4,10 @@ import { NgForm } from '@angular/forms';
import { PasswordSettingService } from './password-setting.service';
import { SessionService } from '../../shared/session.service';
import { AlertType, httpStatusCode } from '../../shared/shared.const';
import { MessageService } from '../../global-message/message.service';
import { errorHandler, isEmptyForm, accessErrorHandler } from '../../shared/shared.utils';
import { InlineAlertComponent } from '../../shared/inline-alert/inline-alert.component';
@Component({
selector: 'password-setting',
@ -14,19 +18,27 @@ export class PasswordSettingComponent implements AfterViewChecked {
oldPwd: string = "";
newPwd: string = "";
reNewPwd: string = "";
error: any = null;
private formValueChanged: boolean = false;
private onCalling: boolean = false;
pwdFormRef: NgForm;
@ViewChild("changepwdForm") pwdForm: NgForm;
constructor(private passwordService: PasswordSettingService, private session: SessionService){}
@ViewChild(InlineAlertComponent)
private inlineAlert: InlineAlertComponent;
constructor(
private passwordService: PasswordSettingService,
private session: SessionService,
private msgService: MessageService) { }
//If form is valid
public get isValid(): boolean {
if (this.pwdForm && this.pwdForm.form.get("newPassword")) {
return this.pwdForm.valid &&
this.pwdForm.form.get("newPassword").value === this.pwdForm.form.get("reNewPassword").value;
(this.pwdForm.form.get("newPassword").value === this.pwdForm.form.get("reNewPassword").value) &&
this.error === null;
}
return false;
}
@ -45,6 +57,8 @@ export class PasswordSettingComponent implements AfterViewChecked {
if (this.pwdFormRef) {
this.pwdFormRef.valueChanges.subscribe(data => {
this.formValueChanged = true;
this.error = null;
this.inlineAlert.close();
});
}
}
@ -54,10 +68,26 @@ export class PasswordSettingComponent implements AfterViewChecked {
open(): void {
this.opened = true;
this.pwdForm.reset();
this.formValueChanged = false;
}
//Close the moal dialog
close(): void {
if (this.formValueChanged) {
if (isEmptyForm(this.pwdForm)) {
this.opened = false;
} else {
//Need user confirmation
this.inlineAlert.showInlineConfirmation({
message: "ALERT.FORM_CHANGE_CONFIRMATION"
});
}
} else {
this.opened = false;
}
}
confirmCancel(): void {
this.opened = false;
}
@ -73,26 +103,31 @@ export class PasswordSettingComponent implements AfterViewChecked {
//Double confirm session is valid
let cUser = this.session.getCurrentUser();
if(!cUser){
if (!cUser) {
return;
}
//Call service
this.onCalling = true;
this.passwordService.changePassword(cUser.user_id,
{
new_password: this.pwdForm.value.newPassword,
old_password: this.pwdForm.value.oldPassword
})
.then(() => {
this.onCalling = false;
this.close();
})
.catch(error => {
this.onCalling = false;
console.error(error);//TODO:
});
//TODO:publish the successful message to general messae box
this.passwordService.changePassword(cUser.user_id,
{
new_password: this.pwdForm.value.newPassword,
old_password: this.pwdForm.value.oldPassword
})
.then(() => {
this.onCalling = false;
this.close();
this.msgService.announceMessage(200, "CHANGE_PWD.SAVE_SUCCESS", AlertType.SUCCESS);
})
.catch(error => {
this.onCalling = false;
this.error = error;
if(accessErrorHandler(error, this.msgService)){
this.opened = false;
}else{
this.inlineAlert.showInlineError(error);
}
});
}
}

View File

@ -5,6 +5,8 @@ import 'rxjs/add/operator/toPromise';
import { PasswordSetting } from './password-setting';
const passwordChangeEndpoint = "/api/users/:user_id/password";
const sendEmailEndpoint = "/sendEmail";
const resetPasswordEndpoint = "/reset";
@Injectable()
export class PasswordSettingService {
@ -19,17 +21,46 @@ export class PasswordSettingService {
constructor(private http: Http) { }
changePassword(userId: number, setting: PasswordSetting): Promise<any> {
if(!setting || setting.new_password.trim()==="" || setting.old_password.trim()===""){
if (!setting || setting.new_password.trim() === "" || setting.old_password.trim() === "") {
return Promise.reject("Invalid data");
}
let putUrl = passwordChangeEndpoint.replace(":user_id", userId+"");
let putUrl = passwordChangeEndpoint.replace(":user_id", userId + "");
return this.http.put(putUrl, JSON.stringify(setting), this.options)
.toPromise()
.then(() => null)
.catch(error=>{
return Promise.reject(error);
});
.toPromise()
.then(() => null)
.catch(error => {
return Promise.reject(error);
});
}
sendResetPasswordMail(email: string): Promise<any> {
if (!email) {
return Promise.reject("Invalid email");
}
let getUrl = sendEmailEndpoint + "?email=" + email;
return this.http.get(getUrl, this.options).toPromise()
.then(response => response)
.catch(error => {
return Promise.reject(error);
})
}
resetPassword(uuid: string, newPassword: string): Promise<any> {
if (!uuid || !newPassword) {
return Promise.reject("Invalid reset uuid or password");
}
return this.http.post(resetPasswordEndpoint, JSON.stringify({
"reset_uuid": uuid,
"password": newPassword
}), this.options)
.toPromise()
.then(response => response)
.catch(error => {
return Promise.reject(error);
});
}
}

View File

@ -0,0 +1,3 @@
.reset-modal-title-override {
font-size: 14px !important;
}

View File

@ -0,0 +1,51 @@
<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="true">
<h3 class="modal-title">{{'RESET_PWD.TITLE' | translate}}</h3>
<label class="modal-title reset-modal-title-override">{{'RESET_PWD.CAPTION2' | translate}}</label>
<div class="modal-body" style="overflow-y: hidden;">
<form #resetPwdForm="ngForm" class="form">
<section class="form-block">
<div class="form-group">
<label for="newPassword">{{'CHANGE_PWD.NEW_PWD' | translate}}</label>
<label for="newPassword" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-left" [class.invalid]='getValidationState("newPassword") === false'>
<input type="password" id="newPassword" placeholder='{{"PLACEHOLDER.NEW_PWD" | translate}}'
required
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{7,}$"
name="newPassword"
[(ngModel)]="password"
#newPassInput="ngModel"
size="25"
(input)='handleValidation("newPassword", true)'
(focusout)='handleValidation("newPassword", false)'>
<span class="tooltip-content">
{{'TOOLTIP.PASSWORD' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="reNewPassword">{{'CHANGE_PWD.CONFIRM_PWD' | translate}}</label>
<label for="reNewPassword" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-bottom-left" [class.invalid]='getValidationState("reNewPassword") === false'>
<input type="password" id="reNewPassword" placeholder='{{"PLACEHOLDER.CONFIRM_PWD" | translate}}'
required
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{7,}$"
name="reNewPassword"
[(ngModel)]="ngModel"
#reNewPassInput
size="25"
(input)='handleValidation("reNewPassword", true)'
(focusout)='handleValidation("reNewPassword", false)'>
<span class="tooltip-content">
{{'TOOLTIP.CONFIRM_PWD' | translate}}
</span>
</label>
</div>
</section>
<inline-alert></inline-alert>
<div style="height: 30px;"></div>
</form>
</div>
<div class="modal-footer">
<span class="spinner spinner-inline" style="top:8px;" [hidden]="showProgress === false"></span>
<button type="button" class="btn btn-outline" (click)="close()">{{'BUTTON.CANCEL' | translate}}</button>
<button type="button" class="btn btn-primary" [disabled]="!isValid || showProgress" (click)="send()">{{'BUTTON.OK' | translate}}</button>
</div>
</clr-modal>

View File

@ -0,0 +1,123 @@
import { Component, ViewChild, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { NgForm } from '@angular/forms';
import { PasswordSettingService } from './password-setting.service';
import { InlineAlertComponent } from '../../shared/inline-alert/inline-alert.component';
import { errorHandler, accessErrorHandler } from '../../shared/shared.utils';
import { AlertType } from '../../shared/shared.const';
import { MessageService } from '../../global-message/message.service';
@Component({
selector: 'reset-password',
templateUrl: "reset-password.component.html",
styleUrls: ['password.component.css']
})
export class ResetPasswordComponent implements OnInit{
opened: boolean = true;
private onGoing: boolean = false;
private password: string = "";
private validationState: any = {};
private resetUuid: string = "";
private resetOk: boolean = false;
@ViewChild("resetPwdForm") resetPwdForm: NgForm;
@ViewChild(InlineAlertComponent)
private inlineAlert: InlineAlertComponent;
constructor(
private pwdService: PasswordSettingService,
private route: ActivatedRoute,
private msgService: MessageService,
private router: Router) { }
ngOnInit(): void {
this.route.queryParams.subscribe(params => this.resetUuid = params["reset_uuid"] || "");
}
public get showProgress(): boolean {
return this.onGoing;
}
public get isValid(): boolean {
return this.resetPwdForm && this.resetPwdForm.valid && this.samePassword();
}
public getValidationState(key: string): boolean {
return this.validationState && this.validationState[key];
}
public open(): void {
this.resetOk = false;
this.opened = true;
this.resetPwdForm.resetForm();
}
public close(): void {
this.opened = false;
}
public send(): void {
//If already reset password ok, navigator to sign-in
if(this.resetOk){
this.router.navigate(['sign-in']);
return;
}
//Double confirm to avoid improper situations
if (!this.password) {
return;
}
if (!this.isValid) {
return;
}
this.onGoing = true;
this.pwdService.resetPassword(this.resetUuid, this.password)
.then(() => {
this.resetOk = true;
this.inlineAlert.showInlineSuccess({message:'RESET_PWD.RESET_OK'});
})
.catch(error => {
if(accessErrorHandler(error, this.msgService)){
this.close();
}else{
this.inlineAlert.showInlineError(errorHandler(error));
}
});
}
public handleValidation(key: string, flag: boolean): void {
if (flag) {
if(!this.validationState[key]){
this.validationState[key] = true;
}
} else {
this.validationState[key] = this.getControlValidationState(key)
}
}
private getControlValidationState(key: string): boolean {
if (this.resetPwdForm) {
let control = this.resetPwdForm.controls[key];
if (control) {
return control.valid;
}
}
return false;
}
private samePassword(): boolean {
if (this.resetPwdForm) {
let control1 = this.resetPwdForm.controls["newPassword"];
let control2 = this.resetPwdForm.controls["reNewPassword"];
if (control1 && control2) {
return control1.value == control2.value;
}
}
return false;
}
}

View File

@ -4,4 +4,12 @@
.visibility-hidden {
visibility: hidden;
}
.forgot-password-link {
position: relative;
line-height: 36px;
font-size: 14px;
float: right;
top: -5px;
}

View File

@ -7,32 +7,33 @@
<label for="username" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-left" [class.invalid]="userNameInput.invalid && (userNameInput.dirty || userNameInput.touched)">
<input class="username" type="text" required
[(ngModel)]="signInCredential.principal"
name="login_username" id="login_username" placeholder="Username"
name="login_username" id="login_username" placeholder='{{"PLACEHOLDER.SIGN_IN_NAME" | translate}}'
#userNameInput='ngModel'>
<span class="tooltip-content">
Username is required!
{{ 'TOOLTIP.SIGN_IN_USERNAME' | translate }}
</span>
</label>
<label for="username" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-left" [class.invalid]="passwordInput.invalid && (passwordInput.dirty || passwordInput.touched)">
<input class="password" type="password" required
[(ngModel)]="signInCredential.password"
name="login_password" id="login_password" placeholder="Password"
name="login_password" id="login_password" placeholder='{{"PLACEHOLDER.SIGN_IN_PWD" | translate}}'
#passwordInput="ngModel">
<span class="tooltip-content">
Password is required!
{{ 'TOOLTIP.SIGN_IN_PWD' | translate }}
</span>
</label>
<div class="checkbox">
<input type="checkbox" id="rememberme">
<label for="rememberme">
Remember me
</label>
<label for="rememberme">{{ 'SIGN_IN.REMEMBER' | translate }}</label>
<a href="javascript:void(0)" class="forgot-password-link" (click)="forgotPassword()">Forgot password</a>
</div>
<div [class.visibility-hidden]="signInStatus != statusError" class="error active">
Invalid user name or password
<div [class.visibility-hidden]="!isError" class="error active">
{{ 'SIGN_IN.INVALID_MSG' | translate }}
</div>
<button [disabled]="signInStatus === statusOnGoing" type="submit" class="btn btn-primary" (click)="signIn()">LOG IN</button>
<a href="javascript:void(0)" class="signup" (click)="signUp()">Sign up for an account</a>
<button [disabled]="isOnGoing || !isValid" type="submit" class="btn btn-primary" (click)="signIn()">{{ 'BUTTON.LOG_IN' | translate }}</button>
<a href="javascript:void(0)" class="signup" (click)="signUp()">{{ 'BUTTON.SIGN_UP_LINK' | translate }}</a>
</div>
</form>
</div>
</div>
<sign-up #signupDialog></sign-up>>
<forgot-password #forgotPwdDialog></forgot-password>

View File

@ -1,11 +1,15 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { Input, ViewChild, AfterViewChecked } from '@angular/core';
import { NgForm } from '@angular/forms';
import { SessionService } from '../../shared/session.service';
import { SignInCredential } from '../../shared/sign-in-credential';
import { SignUpComponent } from '../sign-up/sign-up.component';
import { harborRootRoute } from '../../shared/shared.const';
import { ForgotPasswordComponent } from '../password/forgot-password.component';
//Define status flags for signing in states
export const signInStatusNormal = 0;
export const signInStatusOnGoing = 1;
@ -17,13 +21,16 @@ export const signInStatusError = -1;
styleUrls: ['sign-in.component.css']
})
export class SignInComponent implements AfterViewChecked {
export class SignInComponent implements AfterViewChecked, OnInit {
private redirectUrl: string = "";
//Form reference
signInForm: NgForm;
@ViewChild('signInForm') currentForm: NgForm;
@ViewChild('signupDialog') signUpDialog: SignUpComponent;
@ViewChild('forgotPwdDialog') forgotPwdDialog: ForgotPasswordComponent;
//Status flag
signInStatus: number = 0;
signInStatus: number = signInStatusNormal;
//Initialize sign in credential
@Input() signInCredential: SignInCredential = {
@ -33,22 +40,29 @@ export class SignInComponent implements AfterViewChecked {
constructor(
private router: Router,
private session: SessionService
private session: SessionService,
private route: ActivatedRoute
) { }
//For template accessing
get statusError(): number {
return signInStatusError;
ngOnInit(): void {
this.route.queryParams
.subscribe(params => {
this.redirectUrl = params["redirect_url"] || "";
});
}
get statusOnGoing(): number {
return signInStatusOnGoing;
//For template accessing
public get isError(): boolean {
return this.signInStatus === signInStatusError;
}
public get isOnGoing(): boolean {
return this.signInStatus === signInStatusOnGoing;
}
//Validate the related fields
private validate(): boolean {
return true;
//return this.signInForm.valid;
public get isValid(): boolean {
return this.currentForm.form.valid;
}
//General error handler
@ -93,7 +107,7 @@ export class SignInComponent implements AfterViewChecked {
//Trigger the signin action
signIn(): void {
//Should validate input firstly
if (!this.validate() || this.signInStatus === signInStatusOnGoing) {
if (!this.isValid || this.isOnGoing) {
return;
}
@ -106,25 +120,26 @@ export class SignInComponent implements AfterViewChecked {
//Set status
this.signInStatus = signInStatusNormal;
//Validate the sign-in session
this.session.retrieveUser()
.then(user => {
//Routing to the right location
let nextRoute = ["/harbor", "projects"];
this.router.navigate(nextRoute);
})
.catch(error => {
this.handleError(error);
});
//Redirect to the right route
if (this.redirectUrl === "") {
//Routing to the default location
this.router.navigateByUrl(harborRootRoute);
}else{
this.router.navigateByUrl(this.redirectUrl);
}
})
.catch(error => {
this.handleError(error);
});
}
//Help user navigate to the sign up
//Open sign up dialog
signUp(): void {
let nextRoute = ["/harbor", "signup"];
this.router.navigate(nextRoute);
this.signUpDialog.open();
}
//Open forgot password dialog
forgotPassword(): void {
this.forgotPwdDialog.open();
}
}

View File

@ -2,10 +2,9 @@ import { Injectable } from '@angular/core';
import { Headers, Http, URLSearchParams } from '@angular/http';
import 'rxjs/add/operator/toPromise';
import { SignInCredential } from './sign-in-credential';
import { SignInCredential } from '../../shared/sign-in-credential';
const url_prefix = '/ng';
const signInUrl = url_prefix + '/login';
const signInUrl = '/login';
/**
*
* Define a service to provide sign in methods

View File

@ -1 +1,12 @@
<p>Placeholder for signup</p>
<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="true">
<h3 class="modal-title">{{'SIGN_UP.TITLE' | translate}}</h3>
<div class="modal-body" style="overflow-y: hidden;">
<new-user-form isSelfRegistration="true" (valueChange)="formValueChange($event)"></new-user-form>
<inline-alert (confirmEvt)="confirmCancel($event)"></inline-alert>
</div>
<div class="modal-footer">
<span class="spinner spinner-inline" style="top:8px;" [hidden]="inProgress === false"> </span>
<button type="button" class="btn btn-outline" (click)="close()">{{'BUTTON.CANCEL' | translate}}</button>
<button type="button" class="btn btn-primary" [disabled]="!isValid || inProgress" (click)="create()">{{ 'BUTTON.SIGN_UP' | translate }}</button>
</div>
</clr-modal>

View File

@ -1,10 +1,108 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { Component, Output, ViewChild } from '@angular/core';
import { NgForm } from '@angular/forms';
import { NewUserFormComponent } from '../../shared/new-user-form/new-user-form.component';
import { User } from '../../user/user';
import { SessionService } from '../../shared/session.service';
import { UserService } from '../../user/user.service';
import { errorHandler } from '../../shared/shared.utils';
import { InlineAlertComponent } from '../../shared/inline-alert/inline-alert.component';
@Component({
selector: 'sign-up',
templateUrl: "sign-up.component.html"
})
export class SignUpComponent {
// constructor(private router: Router){}
opened: boolean = false;
staticBackdrop: boolean = true;
private error: any;
private onGoing: boolean = false;
private formValueChanged: boolean = false;
constructor(
private session: SessionService,
private userService: UserService) { }
@ViewChild(NewUserFormComponent)
private newUserForm: NewUserFormComponent;
@ViewChild(InlineAlertComponent)
private inlienAlert: InlineAlertComponent;
private getNewUser(): User {
return this.newUserForm.getData();
}
public get inProgress(): boolean {
return this.onGoing;
}
public get isValid(): boolean {
return this.newUserForm.isValid && this.error == null;
}
formValueChange(flag: boolean): void {
if (flag) {
this.formValueChanged = true;
}
if (this.error != null) {
this.error = null;//clear error
}
this.inlienAlert.close();//Close alert if being shown
}
open(): void {
this.newUserForm.reset();//Reset form
this.formValueChanged = false;
this.opened = true;
}
close(): void {
if (this.formValueChanged) {
if (this.newUserForm.isEmpty()) {
this.opened = false;
} else {
//Need user confirmation
this.inlienAlert.showInlineConfirmation({
message: "ALERT.FORM_CHANGE_CONFIRMATION"
});
}
} else {
this.opened = false;
}
}
confirmCancel(): void {
this.opened = false;
}
//Create new user
create(): void {
//Double confirm everything is ok
//Form is valid
if (!this.isValid) {
return;
}
//We have new user data
let u = this.getNewUser();
if (!u) {
return;
}
//Start process
this.onGoing = true;
this.userService.addUser(u)
.then(() => {
this.onGoing = false;
this.close();
})
.catch(error => {
this.onGoing = false;
this.error = error;
this.inlienAlert.showInlineError(error);
});
}
}

View File

@ -1,5 +1,9 @@
import { Component } from '@angular/core';
// import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { CookieService } from 'angular2-cookie/core';
import { supportedLangs, enLang } from './shared/shared.const';
import { SessionService } from './shared/session.service';
@Component({
selector: 'harbor-app',
@ -7,6 +11,28 @@ import { Component } from '@angular/core';
styleUrls: []
})
export class AppComponent {
// constructor(private router: Router) {
// }
constructor(
private translate: TranslateService,
private cookie: CookieService,
private session: SessionService) {
translate.addLangs(supportedLangs);
translate.setDefaultLang(enLang);
//If user has selected lang, then directly use it
let langSetting = this.cookie.get("harbor-lang");
if (!langSetting || langSetting.trim() === "") {
//Use browser lang
langSetting = translate.getBrowserLang();
}
let selectedLang = this.isLangMatch(langSetting, supportedLangs) ? langSetting : enLang;
translate.use(selectedLang);
//this.session.switchLanguage(selectedLang).catch(error => console.error(error));
}
private isLangMatch(browserLang: string, supportedLangs: string[]) {
if (supportedLangs && supportedLangs.length > 0) {
return supportedLangs.find(lang => lang === browserLang);
}
}
}

View File

@ -9,6 +9,16 @@ import { BaseModule } from './base/base.module';
import { HarborRoutingModule } from './harbor-routing.module';
import { SharedModule } from './shared/shared.module';
import { AccountModule } from './account/account.module';
import { ConfigurationModule } from './config/config.module';
import { TranslateModule, TranslateLoader, MissingTranslationHandler } from "@ngx-translate/core";
import { MyMissingTranslationHandler } from './i18n/missing-trans.handler';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { Http } from '@angular/http';
export function HttpLoaderFactory(http: Http) {
return new TranslateHttpLoader(http, 'ng/i18n/lang/', '-lang.json');
}
@NgModule({
declarations: [
@ -18,7 +28,19 @@ import { AccountModule } from './account/account.module';
SharedModule,
BaseModule,
AccountModule,
HarborRoutingModule
HarborRoutingModule,
ConfigurationModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: (HttpLoaderFactory),
deps: [Http]
},
missingTranslationHandler: {
provide: MissingTranslationHandler,
useClass: MyMissingTranslationHandler
}
})
],
providers: [],
bootstrap: [AppComponent]

View File

@ -1,23 +0,0 @@
import { Injectable } from '@angular/core';
import {
Router, Resolve, ActivatedRouteSnapshot, RouterStateSnapshot
} from '@angular/router';
import { SessionService } from '../shared/session.service';
import { SessionUser } from '../shared/session-user';
@Injectable()
export class BaseRoutingResolver implements Resolve<SessionUser> {
constructor(private session: SessionService, private router: Router) { }
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<SessionUser> {
return this.session.retrieveUser()
.then(sessionUser => {
return sessionUser;
})
.catch(error => {
console.info("Anonymous user");
});
}
}

View File

@ -1,44 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HarborShellComponent } from './harbor-shell/harbor-shell.component';
import { DashboardComponent } from '../dashboard/dashboard.component';
import { ProjectComponent } from '../project/project.component';
import { UserComponent } from '../user/user.component';
import { BaseRoutingResolver } from './base-routing-resolver.service';
const baseRoutes: Routes = [
{
path: 'harbor',
component: HarborShellComponent,
children: [
{
path: 'dashboard',
component: DashboardComponent
},
{
path: 'projects',
component: ProjectComponent
},
{
path: 'users',
component: UserComponent,
resolve: {
projectsResolver: BaseRoutingResolver
}
}
]
}];
@NgModule({
imports: [
RouterModule.forChild(baseRoutes)
],
exports: [RouterModule],
providers: [BaseRoutingResolver]
})
export class BaseRoutingModule {
}

View File

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { RouterModule } from '@angular/router';
import { DashboardModule } from '../dashboard/dashboard.module';
import { ProjectModule } from '../project/project.module';
@ -12,16 +13,14 @@ import { FooterComponent } from './footer/footer.component';
import { HarborShellComponent } from './harbor-shell/harbor-shell.component';
import { SearchResultComponent } from './global-search/search-result.component';
import { BaseRoutingModule } from './base-routing.module';
@NgModule({
imports: [
SharedModule,
DashboardModule,
ProjectModule,
UserModule,
BaseRoutingModule,
AccountModule
AccountModule,
RouterModule
],
declarations: [
NavigatorComponent,

View File

@ -1,5 +1,5 @@
<form class="search">
<label for="search_input">
<input #globalSearchBox id="search_input" type="text" (keyup)="search(globalSearchBox.value)" placeholder="Search Harbor...">
<input #globalSearchBox id="search_input" type="text" (keyup)="search(globalSearchBox.value)" placeholder='{{"GLOBAL_SEARCH.PLACEHOLDER" | translate}}'>
</label>
</form>

View File

@ -1,12 +1,13 @@
.search-overlay {
display: block;
position: absolute;
height: 94%;
height: 100%;
width: 97%;
/*shoud be lesser than 1000 to aoivd override the popup menu*/
z-index: 999;
box-sizing: border-box;
background: #fafafa;
top: 0px;
}
.search-header {

View File

@ -1,25 +1,24 @@
<clr-main-container>
<global-message [isAppLevel]="true"></global-message>
<navigator (showAccountSettingsModal)="openModal($event)" (searchEvt)="doSearch($event)" (showPwdChangeModal)="openModal($event)"></navigator>
<global-message></global-message>
<div class="content-container">
<div class="content-area" [class.container-override]="showSearch">
<global-message [isAppLevel]="false"></global-message>
<!-- Only appear when searching -->
<search-result (closeEvt)="searchClose($event)"></search-result>
<router-outlet></router-outlet>
</div>
<nav class="sidenav" [class.side-nav-override]="showSearch">
<nav class="sidenav" *ngIf="isUserExisting" [class.side-nav-override]="showSearch" (click)='watchClickEvt()'>
<section class="sidenav-content">
<a routerLink="/harbor/projects" routerLinkActive="active" class="nav-link">
Projects
</a>
<section class="nav-group collapsible">
<a routerLink="/harbor/projects" routerLinkActive="active" class="nav-link">{{'SIDE_NAV.PROJECTS' | translate}}</a>
<a routerLink="/harbor/logs" routerLinkActive="active" class="nav-link">{{'SIDE_NAV.LOGS' | translate}}</a>
<section class="nav-group collapsible" *ngIf="isSystemAdmin">
<input id="tabsystem" type="checkbox">
<label for="tabsystem">System Managements</label>
<label for="tabsystem">{{'SIDE_NAV.SYSTEM_MGMT.NAME' | translate}}</label>
<ul class="nav-list">
<li><a class="nav-link" routerLink="/harbor/users" routerLinkActive="active">Users</a></li>
<li><a class="nav-link">Replications</a></li>
<li><a class="nav-link">Quarantine[*]</a></li>
<li><a class="nav-link">Configurations[*]</a></li>
<li><a class="nav-link" routerLink="/harbor/users" routerLinkActive="active">{{'SIDE_NAV.SYSTEM_MGMT.USERS' | translate}}</a></li>
<li><a class="nav-link" routerLink="/harbor/replications/endpoints" routerLinkActive="active">{{'SIDE_NAV.SYSTEM_MGMT.REPLICATIONS' | translate}}</a></li>
<li><a class="nav-link" routerLink="/harbor/configs" routerLinkActive="active">{{'SIDE_NAV.SYSTEM_MGMT.CONFIGS' | translate}}</a></li>
</ul>
</section>
</section>
@ -27,4 +26,6 @@
</div>
</clr-main-container>
<account-settings-modal></account-settings-modal>
<password-setting></password-setting>
<password-setting></password-setting>
<deletion-dialog></deletion-dialog>
<about-dialog></about-dialog>

View File

@ -3,12 +3,15 @@ import { Router, ActivatedRoute } from '@angular/router';
import { ModalEvent } from '../modal-event';
import { SearchEvent } from '../search-event';
import { modalAccountSettings, modalPasswordSetting } from '../modal-events.const';
import { modalEvents } from '../modal-events.const';
import { AccountSettingsModalComponent } from '../../account/account-settings/account-settings-modal.component';
import { SearchResultComponent } from '../global-search/search-result.component';
import { PasswordSettingComponent } from '../../account/password/password-setting.component';
import { NavigatorComponent } from '../navigator/navigator.component';
import { SessionService } from '../../shared/session.service';
import { AboutDialogComponent } from '../../shared/about-dialog/about-dialog.component'
@Component({
selector: 'harbor-shell',
@ -30,11 +33,16 @@ export class HarborShellComponent implements OnInit {
@ViewChild(NavigatorComponent)
private navigator: NavigatorComponent;
@ViewChild(AboutDialogComponent)
private aboutDialog: AboutDialogComponent;
//To indicator whwther or not the search results page is displayed
//We need to use this property to do some overriding work
private isSearchResultsOpened: boolean = false;
constructor(private route: ActivatedRoute) { }
constructor(
private route: ActivatedRoute,
private session: SessionService) { }
ngOnInit() {
this.route.data.subscribe(data => {
@ -46,15 +54,28 @@ export class HarborShellComponent implements OnInit {
return this.isSearchResultsOpened;
}
public get isSystemAdmin(): boolean {
let account = this.session.getCurrentUser();
return account != null && account.has_admin_role > 0;
}
public get isUserExisting(): boolean {
let account = this.session.getCurrentUser();
return account != null;
}
//Open modal dialog
openModal(event: ModalEvent): void {
switch (event.modalName) {
case modalAccountSettings:
case modalEvents.USER_PROFILE:
this.accountSettingsModal.open();
break;
case modalPasswordSetting:
case modalEvents.CHANGE_PWD:
this.pwdSetting.open();
break;
case modalEvents.ABOUT:
this.aboutDialog.open();
break;
default:
break;
}
@ -77,4 +98,10 @@ export class HarborShellComponent implements OnInit {
this.isSearchResultsOpened = false;
}
}
//Close serch result panel if existing
watchClickEvt(): void {
this.searchResultComponet.close();
this.isSearchResultsOpened = false;
}
}

View File

@ -1,5 +1,7 @@
import { modalEvents } from './modal-events.const'
//Define a object to store the modal event
export class ModalEvent {
modalName: string;
modalName: modalEvents;
modalFlag: boolean; //true for open, false for close
}

View File

@ -1,2 +1,3 @@
export const modalAccountSettings= "account-settings";
export const modalPasswordSetting = "password-setting";
export const enum modalEvents {
USER_PROFILE, CHANGE_PWD, ABOUT
}

View File

@ -13,4 +13,8 @@
padding: 2px 0px 2px 0px;
vertical-align: middle;
height: 24px;
}
.lang-selected {
font-weight: bold;
}

View File

@ -1,27 +1,21 @@
<clr-header class="header-5 header">
<div class="branding">
<a href="#" class="nav-link">
<a href="javascript:void(0)" class="nav-link" (click)="homeAction()">
<clr-icon shape="vm-bug"></clr-icon>
<span class="title">Harbor</span>
</a>
</div>
<global-search (searchEvt)="transferSearchEvent($event)"></global-search>
<div class="header-actions">
<div *ngIf="!isSessionValid">
<a href="javascript:void(0)" class="nav-link nav-text sign-in-override" routerLink="/sign-in" routerLinkActive="active">Sign In</a>
<span class="custom-divider"></span>
<a href="javascript:void(0)" class="nav-link nav-text sign-up-override" routerLink="/sign-up" routerLinkActive="active">Sign Up</a>
</div>
<clr-dropdown class="dropdown bottom-left">
<button class="nav-icon" clrDropdownToggle style="width: 90px;">
<clr-icon shape="world" style="left:-5px;"></clr-icon>
<span>English</span>
<clr-icon shape="world" style="left:-8px;"></clr-icon>
<span>{{currentLang}}</span>
<clr-icon shape="caret down"></clr-icon>
</button>
<div class="dropdown-menu">
<a href="javascript:void(0)" clrDropdownItem>English</a>
<a href="javascript:void(0)" clrDropdownItem>中文简体</a>
<a href="javascript:void(0)" clrDropdownItem>中文繁體</a>
<a href="javascript:void(0)" clrDropdownItem (click)='switchLanguage("en")' [class.lang-selected]='matchLang("en")'>English</a>
<a href="javascript:void(0)" clrDropdownItem (click)='switchLanguage("zh")' [class.lang-selected]='matchLang("zh")'>中文简体</a>
</div>
</clr-dropdown>
<clr-dropdown [clrMenuPosition]="'bottom-right'" class="dropdown" *ngIf="isSessionValid">
@ -31,12 +25,13 @@
<clr-icon shape="caret down"></clr-icon>
</button>
<div class="dropdown-menu">
<a href="javascript:void(0)" clrDropdownItem (click)="openAccountSettingsModal()">User Profile</a>
<a href="javascript:void(0)" clrDropdownItem (click)="openChangePwdModal()">Change Password</a>
<a href="javascript:void(0)" clrDropdownItem>About</a>
<a href="javascript:void(0)" clrDropdownItem (click)="openAccountSettingsModal()">{{'ACCOUNT_SETTINGS.PROFILE' | translate}}</a>
<a href="javascript:void(0)" clrDropdownItem (click)="openChangePwdModal()">{{'ACCOUNT_SETTINGS.CHANGE_PWD' | translate}}</a>
<a href="javascript:void(0)" clrDropdownItem (click)="openAboutDialog()">{{'ACCOUNT_SETTINGS.ABOUT' | translate}}</a>
<div class="dropdown-divider"></div>
<a href="javascript:void(0)" clrDropdownItem (click)="logOut()">Log out</a>
<a href="javascript:void(0)" clrDropdownItem (click)="logOut()">{{'ACCOUNT_SETTINGS.LOGOUT' | translate}}</a>
</div>
</clr-dropdown>
<a href="javascript:void(0)" class="nav-link nav-text" (click)="openAboutDialog()" *ngIf="isSessionValid === false">{{'ACCOUNT_SETTINGS.ABOUT' | translate}}</a>
</div>
</clr-header>

View File

@ -1,12 +1,16 @@
import { Component, Output, EventEmitter, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { ModalEvent } from '../modal-event';
import { SearchEvent } from '../search-event';
import { modalAccountSettings, modalPasswordSetting } from '../modal-events.const';
import { modalEvents } from '../modal-events.const';
import { SessionUser } from '../../shared/session-user';
import { SessionService } from '../../shared/session.service';
import { CookieService } from 'angular2-cookie/core';
import { supportedLangs, enLang, languageNames } from '../../shared/shared.const';
@Component({
selector: 'navigator',
@ -21,11 +25,22 @@ export class NavigatorComponent implements OnInit {
@Output() showPwdChangeModal = new EventEmitter<ModalEvent>();
private sessionUser: SessionUser = null;
private selectedLang: string = enLang;
constructor(private session: SessionService, private router: Router) { }
constructor(
private session: SessionService,
private router: Router,
private translate: TranslateService,
private cookie: CookieService) { }
ngOnInit(): void {
this.sessionUser = this.session.getCurrentUser();
this.selectedLang = this.translate.currentLang;
this.translate.onLangChange.subscribe(langChange => {
this.selectedLang = langChange.lang;
//Keep in cookie for next use
this.cookie.put("harbor-lang", langChange.lang);
});
}
public get isSessionValid(): boolean {
@ -33,13 +48,21 @@ export class NavigatorComponent implements OnInit {
}
public get accountName(): string {
return this.sessionUser?this.sessionUser.username: "";
return this.sessionUser ? this.sessionUser.username : "";
}
public get currentLang(): string {
return languageNames[this.selectedLang];
}
matchLang(lang: string): boolean {
return lang.trim() === this.selectedLang;
}
//Open the account setting dialog
openAccountSettingsModal(): void {
this.showAccountSettingsModal.emit({
modalName: modalAccountSettings,
modalName: modalEvents.USER_PROFILE,
modalFlag: true
});
}
@ -47,7 +70,15 @@ export class NavigatorComponent implements OnInit {
//Open change password dialog
openChangePwdModal(): void {
this.showPwdChangeModal.emit({
modalName: modalPasswordSetting,
modalName: modalEvents.CHANGE_PWD,
modalFlag: true
});
}
//Open about dialog
openAboutDialog(): void {
this.showPwdChangeModal.emit({
modalName: modalEvents.ABOUT,
modalFlag: true
});
}
@ -67,4 +98,28 @@ export class NavigatorComponent implements OnInit {
})
.catch()//TODO:
}
//Switch languages
switchLanguage(lang: string): void {
if (supportedLangs.find(supportedLang => supportedLang === lang.trim())){
this.translate.use(lang);
}else{
this.translate.use(enLang);//Use default
//TODO:
console.error('Language '+lang.trim()+' is not suppoted');
}
//Try to switch backend lang
//this.session.switchLanguage(lang).catch(error => console.error(error));
}
//Handle the home action
homeAction(): void {
if(this.sessionUser != null){
//Navigate to default page
this.router.navigate(['harbor','projects']);
}else{
//Naviagte to signin page
this.router.navigate(['sign-in']);
}
}
}

View File

@ -0,0 +1,117 @@
<form #authConfigFrom="ngForm" class="form">
<section class="form-block">
<div class="form-group">
<label for="authMode">{{'CONFIG.AUTH_MODE' | translate }}</label>
<div class="select">
<select id="authMode" name="authMode" [disabled]="disabled(currentConfig.auth_mode)" [(ngModel)]="currentConfig.auth_mode.value">
<option>db_auth</option>
<option>ldap</option>
</select>
</div>
</div>
</section>
<section class="form-block" *ngIf="showLdap">
<div class="form-group">
<label for="ldapUrl" class="required">LDAP URL</label>
<label for="ldapUrl" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="ldapUrlInput.invalid && (ldapUrlInput.dirty || ldapUrlInput.touched)">
<input name="ldapUrl" type="text" #ldapUrlInput="ngModel" [(ngModel)]="currentConfig.ldap_url.value"
required
id="ldapUrl"
size="40"
[disabled]="disabled(currentConfig.ldap_url)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="ldapSearchDN" class="required">LDAP Search DN</label>
<label for="ldapSearchDN" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="ldapSearchDNInput.invalid && (ldapSearchDNInput.dirty || ldapSearchDNInput.touched)">
<input name="ldapSearchDN" type="text" #ldapSearchDNInput="ngModel" [(ngModel)]="currentConfig.ldap_search_dn.value"
required
id="ldapSearchDN"
size="40" [disabled]="disabled(currentConfig.ldap_search_dn)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="ldapSearchPwd" class="required">LDAP Search Password</label>
<label for="ldapSearchPwd" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="ldapSearchPwdInput.invalid && (ldapSearchPwdInput.dirty || ldapSearchPwdInput.touched)">
<input name="ldapSearchPwd" type="password" #ldapSearchPwdInput="ngModel" [(ngModel)]="currentConfig.ldap_search_password.value"
required
id="ldapSearchPwd"
size="40" [disabled]="disabled(currentConfig.ldap_search_password)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="ldapBaseDN" class="required">LDAP Base DN</label>
<label for="ldapBaseDN" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="ldapBaseDNInput.invalid && (ldapBaseDNInput.dirty || ldapBaseDNInput.touched)">
<input name="ldapBaseDN" type="text" #ldapBaseDNInput="ngModel" [(ngModel)]="currentConfig.ldap_base_dn.value"
required
id="ldapBaseDN"
size="40" [disabled]="disabled(currentConfig.ldap_base_dn)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="ldapFilter">LDAP Filter</label>
<label for="ldapFilter" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right">
<input name="ldapFilter" type="text" #ldapFilterInput="ngModel" [(ngModel)]="currentConfig.ldap_filter.value"
id="ldapFilter"
size="40" [disabled]="disabled(currentConfig.ldap_filter)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="ldapUid" class="required">LDAP UID</label>
<label for="ldapUid" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="ldapUidInput.invalid && (ldapUidInput.dirty || ldapUidInput.touched)">
<input name="ldapUid" type="text" #ldapUidInput="ngModel" [(ngModel)]="currentConfig.ldap_uid.value"
required
id="ldapUid"
size="40" [disabled]="disabled(currentConfig.ldap_uid)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="ldapScope">lDAP Scope</label>
<div class="select">
<select id="ldapScope" name="ldapScope" [(ngModel)]="currentConfig.ldap_scope.value" [disabled]="disabled(currentConfig.ldap_scope)">
<option value="1">Base</option>
<option value="2">OneLevel</option>
<option value="3">Subtree</option>
</select>
</div>
</div>
</section>
<section class="form-block">
<div class="form-group">
<label for="proCreation">{{'CONFIG.PRO_CREATION_RESTRICTION' | translate}}</label>
<div class="select">
<select id="proCreation" name="proCreation" [(ngModel)]="currentConfig.project_creation_restriction.value" [disabled]="disabled(currentConfig.project_creation_restriction)">
<option>everyone</option>
<option>adminonly</option>
</select>
</div>
</div>
<div class="form-group">
<label for="selfReg">{{'CONFIG.SELF_REGISTRATION' | translate}}</label>
<clr-checkbox name="selfReg" id="selfReg" [(ngModel)]="currentConfig.self_registration.value" [disabled]="disabled(currentConfig.self_registration)">
<a href="javascript:void(0)" role="tooltip" aria-haspopup="true" class="tooltip tooltip-top-right" style="top:-8px;">
<clr-icon shape="info-circle" class="is-info" size="24"></clr-icon>
<span class="tooltip-content">{{'CONFIG.SELF_REGISTRATION_TOOLTIP' | translate}}</span>
</a>
</clr-checkbox>
</div>
</section>
</form>

View File

@ -0,0 +1,33 @@
import { Component, Input, ViewChild } from '@angular/core';
import { NgForm } from '@angular/forms';
import { Subscription } from 'rxjs/Subscription';
import { Configuration } from '../config';
@Component({
selector: 'config-auth',
templateUrl: "config-auth.component.html",
styleUrls: ['../config.component.css']
})
export class ConfigurationAuthComponent {
private changeSub: Subscription;
@Input("ldapConfig") currentConfig: Configuration = new Configuration();
@ViewChild("authConfigFrom") authForm: NgForm;
constructor() { }
public get showLdap(): boolean {
return this.currentConfig &&
this.currentConfig.auth_mode &&
this.currentConfig.auth_mode.value === 'ldap';
}
private disabled(prop: any): boolean {
return !(prop && prop.editable);
}
public isValid(): boolean {
return this.authForm && this.authForm.valid;
}
}

View File

@ -0,0 +1,54 @@
<h1 style="display: inline-block;">{{'CONFIG.TITLE' | translate }}</h1>
<span class="spinner spinner-inline" [hidden]="inProgress === false"></span>
<clr-tabs (clrTabsCurrentTabLinkChanged)="tabLinkChanged($event)">
<clr-tab-link [clrTabLinkId]="'config-auth'" [clrTabLinkActive]="true">{{'CONFIG.AUTH' | translate }}</clr-tab-link>
<clr-tab-link [clrTabLinkId]="'config-replication'">{{'CONFIG.REPLICATION' | translate }}</clr-tab-link>
<clr-tab-link [clrTabLinkId]="'config-email'">{{'CONFIG.EMAIL' | translate }}</clr-tab-link>
<clr-tab-link [clrTabLinkId]="'config-system'">{{'CONFIG.SYSTEM' | translate }}</clr-tab-link>
<clr-tab-content [clrTabContentId]="'authentication'" [clrTabContentActive]="true">
<config-auth [ldapConfig]="allConfig"></config-auth>
</clr-tab-content>
<clr-tab-content [clrTabContentId]="'replication'">
<form #repoConfigFrom="ngForm" class="form">
<section class="form-block">
<div class="form-group">
<label for="verifyRemoteCert">{{'CONFIG.VERIFY_REMOTE_CERT' | translate }}</label>
<clr-checkbox name="verifyRemoteCert" id="verifyRemoteCert" [(ngModel)]="allConfig.verify_remote_cert.value" [disabled]="disabled(allConfig.verify_remote_cert)">
<a href="javascript:void(0)" role="tooltip" aria-haspopup="true" class="tooltip tooltip-lg tooltip-top-right" style="top:-8px;">
<clr-icon shape="info-circle" class="is-info" size="24"></clr-icon>
<span class="tooltip-content">{{'CONFIG.VERIFY_REMOTE_CERT_TOOLTIP' | translate }}</span>
</a>
</clr-checkbox>
</div>
</section>
</form>
</clr-tab-content>
<clr-tab-content [clrTabContentId]="'email'">
<config-email [mailConfig]="allConfig"></config-email>
</clr-tab-content>
<clr-tab-content [clrTabContentId]="'system_settings'">
<form #systemConfigFrom="ngForm" class="form">
<section class="form-block">
<div class="form-group">
<label for="tokenExpiration" class="required">{{'CONFIG.TOKEN_EXPIRATION' | translate}}</label>
<label for="tokenExpiration" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="tokenExpirationInput.invalid && (tokenExpirationInput.dirty || tokenExpirationInput.touched)">
<input name="tokenExpiration" type="text" #tokenExpirationInput="ngModel" [(ngModel)]="allConfig.token_expiration.value"
required
pattern="^[1-9]{1}[\d]*$"
id="tokenExpiration"
size="40" [disabled]="disabled(allConfig.token_expiration)">
<span class="tooltip-content">
{{'TOOLTIP.NUMBER_REQUIRED' | translate}}
</span>
</label>
</div>
</section>
</form>
</clr-tab-content>
</clr-tabs>
<div>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="!isValid() || !hasChanges()">{{'BUTTON.SAVE' | translate}}</button>
<button type="button" class="btn btn-outline" (click)="cancel()" [disabled]="!isValid() || !hasChanges()">{{'BUTTON.CANCEL' | translate}}</button>
<button type="button" class="btn btn-outline" (click)="testMailServer()" *ngIf="showTestServerBtn" [disabled]="!isMailConfigValid()">{{'BUTTON.TEST_MAIL' | translate}}</button>
</div>

View File

@ -0,0 +1,267 @@
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { NgForm } from '@angular/forms';
import { ConfigurationService } from './config.service';
import { Configuration } from './config';
import { MessageService } from '../global-message/message.service';
import { AlertType, DeletionTargets } from '../shared/shared.const';
import { errorHandler, accessErrorHandler } from '../shared/shared.utils';
import { StringValueItem } from './config';
import { DeletionDialogService } from '../shared/deletion-dialog/deletion-dialog.service';
import { Subscription } from 'rxjs/Subscription';
import { DeletionMessage } from '../shared/deletion-dialog/deletion-message'
import { ConfigurationAuthComponent } from './auth/config-auth.component';
import { ConfigurationEmailComponent } from './email/config-email.component';
const fakePass = "fakepassword";
@Component({
selector: 'config',
templateUrl: "config.component.html",
styleUrls: ['config.component.css']
})
export class ConfigurationComponent implements OnInit, OnDestroy {
private onGoing: boolean = false;
allConfig: Configuration = new Configuration();
private currentTabId: string = "";
private originalCopy: Configuration;
private confirmSub: Subscription;
@ViewChild("repoConfigFrom") repoConfigForm: NgForm;
@ViewChild("systemConfigFrom") systemConfigForm: NgForm;
@ViewChild(ConfigurationEmailComponent) mailConfig: ConfigurationEmailComponent;
@ViewChild(ConfigurationAuthComponent) authConfig: ConfigurationAuthComponent;
constructor(
private configService: ConfigurationService,
private msgService: MessageService,
private confirmService: DeletionDialogService) { }
ngOnInit(): void {
//First load
this.retrieveConfig();
this.confirmSub = this.confirmService.deletionConfirm$.subscribe(confirmation => {
this.reset(confirmation.data);
});
}
ngOnDestroy(): void {
if (this.confirmSub) {
this.confirmSub.unsubscribe();
}
}
public get inProgress(): boolean {
return this.onGoing;
}
public isValid(): boolean {
return this.repoConfigForm &&
this.repoConfigForm.valid &&
this.systemConfigForm &&
this.systemConfigForm.valid &&
this.mailConfig &&
this.mailConfig.isValid() &&
this.authConfig &&
this.authConfig.isValid();
}
public hasChanges(): boolean {
return !this.isEmpty(this.getChanges());
}
public isMailConfigValid(): boolean {
return this.mailConfig &&
this.mailConfig.isValid();
}
public get showTestServerBtn(): boolean {
return this.currentTabId === 'config-email';
}
public tabLinkChanged(tabLink: any) {
this.currentTabId = tabLink.id;
}
/**
*
* Save the changed values
*
* @memberOf ConfigurationComponent
*/
public save(): void {
let changes = this.getChanges();
if (!this.isEmpty(changes)) {
this.onGoing = true;
this.configService.saveConfiguration(changes)
.then(response => {
this.onGoing = false;
//API should return the updated configurations here
//Unfortunately API does not do that
//To refresh the view, we can clone the original data copy
//or force refresh by calling service.
//HERE we choose force way
this.retrieveConfig();
this.msgService.announceMessage(response.status, "CONFIG.SAVE_SUCCESS", AlertType.SUCCESS);
})
.catch(error => {
this.onGoing = false;
if (!accessErrorHandler(error, this.msgService)) {
this.msgService.announceMessage(error.status, errorHandler(error), AlertType.DANGER);
}
});
} else {
//Inprop situation, should not come here
console.error("Save obort becasue nothing changed");
}
}
/**
*
* Discard current changes if have and reset
*
* @memberOf ConfigurationComponent
*/
public cancel(): void {
let changes = this.getChanges();
if (!this.isEmpty(changes)) {
let msg = new DeletionMessage(
"CONFIG.CONFIRM_TITLE",
"CONFIG.CONFIRM_SUMMARY",
"",
changes,
DeletionTargets.EMPTY
);
this.confirmService.openComfirmDialog(msg);
} else {
//Inprop situation, should not come here
console.error("Nothing changed");
}
}
/**
*
* Test the connection of specified mail server
*
*
* @memberOf ConfigurationComponent
*/
public testMailServer(): void {
}
private retrieveConfig(): void {
this.onGoing = true;
this.configService.getConfiguration()
.then(configurations => {
this.onGoing = false;
//Add two password fields
configurations.email_password = new StringValueItem(fakePass, true);
configurations.ldap_search_password = new StringValueItem(fakePass, true);
this.allConfig = configurations;
//Keep the original copy of the data
this.originalCopy = this.clone(configurations);
})
.catch(error => {
this.onGoing = false;
if (!accessErrorHandler(error, this.msgService)) {
this.msgService.announceMessage(error.status, errorHandler(error), AlertType.DANGER);
}
});
}
/**
*
* Get the changed fields and return a map
*
* @private
* @returns {*}
*
* @memberOf ConfigurationComponent
*/
private getChanges(): any {
let changes = {};
if (!this.allConfig || !this.originalCopy) {
return changes;
}
for (let prop in this.allConfig) {
let field = this.originalCopy[prop];
if (field && field.editable) {
if (field.value != this.allConfig[prop].value) {
changes[prop] = this.allConfig[prop].value;
//Fix boolean issue
if (typeof field.value === "boolean") {
changes[prop] = changes[prop] ? "1" : "0";
}
}
}
}
return changes;
}
/**
*
* Deep clone the configuration object
*
* @private
* @param {Configuration} src
* @returns {Configuration}
*
* @memberOf ConfigurationComponent
*/
private clone(src: Configuration): Configuration {
let dest = new Configuration();
if (!src) {
return dest;//Empty
}
for (let prop in src) {
if (src[prop]) {
dest[prop] = Object.assign({}, src[prop]); //Deep copy inner object
}
}
return dest;
}
/**
*
* Reset the configuration form
*
* @private
* @param {*} changes
*
* @memberOf ConfigurationComponent
*/
private reset(changes: any): void {
if (!this.isEmpty(changes)) {
for (let prop in changes) {
if (this.originalCopy[prop]) {
this.allConfig[prop] = Object.assign({}, this.originalCopy[prop]);
}
}
} else {
//force reset
this.retrieveConfig();
}
}
private isEmpty(obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key))
return false;
}
return true;
}
private disabled(prop: any): boolean {
return !(prop && prop.editable);
}
}

View File

@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { CoreModule } from '../core/core.module';
import { SharedModule } from '../shared/shared.module';
import { ConfigurationComponent } from './config.component';
import { ConfigurationService } from './config.service';
import { ConfigurationAuthComponent } from './auth/config-auth.component';
import { ConfigurationEmailComponent } from './email/config-email.component';
@NgModule({
imports: [
CoreModule,
SharedModule
],
declarations: [
ConfigurationComponent,
ConfigurationAuthComponent,
ConfigurationEmailComponent],
exports: [ConfigurationComponent],
providers: [ConfigurationService]
})
export class ConfigurationModule { }

View File

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Headers, Http, RequestOptions } from '@angular/http';
import 'rxjs/add/operator/toPromise';
import { Configuration } from './config';
const configEndpoint = "/api/configurations";
@Injectable()
export class ConfigurationService {
private headers: Headers = new Headers({
"Accept": 'application/json',
"Content-Type": 'application/json'
});
private options: RequestOptions = new RequestOptions({
'headers': this.headers
});
constructor(private http: Http) { }
public getConfiguration(): Promise<Configuration> {
return this.http.get(configEndpoint, this.options).toPromise()
.then(response => response.json() as Configuration)
.catch(error => Promise.reject(error));
}
public saveConfiguration(values: any): Promise<any> {
return this.http.put(configEndpoint, JSON.stringify(values), this.options)
.toPromise()
.then(response => response)
.catch(error => Promise.reject(error));
}
}

View File

@ -0,0 +1,77 @@
export class StringValueItem {
value: string;
editable: boolean;
public constructor(v: string, e: boolean) {
this.value = v;
this.editable = e;
}
}
export class NumberValueItem {
value: number;
editable: boolean;
public constructor(v: number, e: boolean) {
this.value = v;
this.editable = e;
}
}
export class BoolValueItem {
value: boolean;
editable: boolean;
public constructor(v: boolean, e: boolean) {
this.value = v;
this.editable = e;
}
}
export class Configuration {
auth_mode: StringValueItem;
project_creation_restriction: StringValueItem;
self_registration: BoolValueItem;
ldap_base_dn: StringValueItem;
ldap_filter?: StringValueItem;
ldap_scope: NumberValueItem;
ldap_search_dn?: StringValueItem;
ldap_search_password?: StringValueItem;
ldap_timeout: NumberValueItem;
ldap_uid: StringValueItem;
ldap_url: StringValueItem;
email_host: StringValueItem;
email_identity: StringValueItem;
email_from: StringValueItem;
email_port: NumberValueItem;
email_ssl: BoolValueItem;
email_username?: StringValueItem;
email_password?: StringValueItem;
verify_remote_cert: BoolValueItem;
token_expiration: NumberValueItem;
cfg_expiration: NumberValueItem;
public constructor() {
this.auth_mode = new StringValueItem("db_auth", true);
this.project_creation_restriction = new StringValueItem("everyone", true);
this.self_registration = new BoolValueItem(false, true);
this.ldap_base_dn = new StringValueItem("", true);
this.ldap_filter = new StringValueItem("", true);
this.ldap_scope = new NumberValueItem(0, true);
this.ldap_search_dn = new StringValueItem("", true);
this.ldap_search_password = new StringValueItem("", true);
this.ldap_timeout = new NumberValueItem(5, true);
this.ldap_uid = new StringValueItem("", true);
this.ldap_url = new StringValueItem("", true);
this.email_host = new StringValueItem("", true);
this.email_identity = new StringValueItem("", true);
this.email_from = new StringValueItem("", true);
this.email_port = new NumberValueItem(25, true);
this.email_ssl = new BoolValueItem(false, true);
this.email_username = new StringValueItem("", true);
this.email_password = new StringValueItem("", true);
this.token_expiration = new NumberValueItem(5, true);
this.cfg_expiration = new NumberValueItem(30, true);
this.verify_remote_cert = new BoolValueItem(false, true);
}
}

View File

@ -0,0 +1,74 @@
<form #mailConfigFrom="ngForm" class="form">
<section class="form-block">
<div class="form-group">
<label for="mailServer" class="required">{{'CONFIG.MAIL_SERVER' | translate}}</label>
<label for="mailServer" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="mailServerInput.invalid && (mailServerInput.dirty || mailServerInput.touched)">
<input name="mailServer" type="text" #mailServerInput="ngModel" [(ngModel)]="currentConfig.email_host.value"
required
id="mailServer"
size="40" [disabled]="disabled(currentConfig.email_host)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="emailPort" class="required">{{'CONFIG.MAIL_SERVER_PORT' | translate}}</label>
<label for="emailPort" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="emailPortInput.invalid && (emailPortInput.dirty || emailPortInput.touched)">
<input name="emailPort" type="text" #emailPortInput="ngModel" [(ngModel)]="currentConfig.email_port.value"
required
port
id="emailPort"
size="40" [disabled]="disabled(currentConfig.email_port)">
<span class="tooltip-content">
{{'TOOLTIP.PORT_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="emailUsername">{{'CONFIG.MAIL_USERNAME' | translate}}</label>
<label for="emailUsername" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="false">
<input name="emailUsername" type="text" #emailUsernameInput="ngModel" [(ngModel)]="currentConfig.email_username.value"
required
id="emailUsername"
size="40" [disabled]="disabled(currentConfig.email_username)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="emailPassword">{{'CONFIG.MAIL_PASSWORD' | translate}}</label>
<label for="emailPassword" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="false">
<input name="emailPassword" type="password" #emailPasswordInput="ngModel" [(ngModel)]="currentConfig.email_password.value"
required
id="emailPassword"
size="40" [disabled]="disabled(currentConfig.email_password)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="emailFrom" class="required">{{'CONFIG.MAIL_FROM' | translate}}</label>
<label for="emailFrom" aria-haspopup="true" role="tooltip" class="tooltip tooltip-validation tooltip-md tooltip-top-right" [class.invalid]="emailFromInput.invalid && (emailFromInput.dirty || emailFromInput.touched)">
<input name="emailFrom" type="text" #emailFromInput="ngModel" [(ngModel)]="currentConfig.email_from.value"
required
id="emailFrom"
size="40" [disabled]="disabled(currentConfig.email_from)">
<span class="tooltip-content">
{{'TOOLTIP.ITEM_REQUIRED' | translate}}
</span>
</label>
</div>
<div class="form-group">
<label for="selfReg">{{'CONFIG.MAIL_SSL' | translate}}</label>
<clr-checkbox name="emailSSL" id="emailSSL" [(ngModel)]="currentConfig.email_ssl.value" [disabled]="disabled(currentConfig.email_ssl)">
<a href="javascript:void(0)" role="tooltip" aria-haspopup="true" class="tooltip tooltip-top-right" style="top:-8px;">
<clr-icon shape="info-circle" class="is-info" size="24"></clr-icon>
<span class="tooltip-content">{{'CONFIG.SSL_TOOLTIP' | translate}}</span>
</a>
</clr-checkbox>
</div>
</section>
</form>

View File

@ -0,0 +1,25 @@
import { Component, Input, ViewChild } from '@angular/core';
import { NgForm } from '@angular/forms';
import { Configuration } from '../config';
@Component({
selector: 'config-email',
templateUrl: "config-email.component.html",
styleUrls: ['../config.component.css']
})
export class ConfigurationEmailComponent {
@Input("mailConfig") currentConfig: Configuration = new Configuration();
@ViewChild("mailConfigFrom") mailForm: NgForm;
constructor() { }
private disabled(prop: any): boolean {
return !(prop && prop.editable);
}
public isValid(): boolean {
return this.mailForm && this.mailForm.valid;
}
}

View File

@ -1,7 +1,10 @@
<clr-alert [clrAlertType]="'alert-danger'" [clrAlertAppLevel]="true" [(clrAlertClosed)]="!globalMessageOpened" (clrAlertClosedChange)="onClose()">
<div class="alert-item">
<span class="alert-text">
{{globalMessage}}
</span>
</div>
<clr-alert [clrAlertType]="globalMessage.type" [clrAlertAppLevel]="isAppLevel" [(clrAlertClosed)]="!globalMessageOpened" (clrAlertClosedChange)="onClose()">
<div class="alert-item">
<span class="alert-text">
{{message}}
</span>
<div class="alert-actions" *ngIf="needAuth">
<button class="btn alert-action" (click)="signIn()">{{ 'BUTTON.LOG_IN' | translate }}</button>
</div>
</div>
</clr-alert>

View File

@ -1,23 +1,100 @@
import { Component } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Message } from './message';
import { MessageService } from './message.service';
import { AlertType, dismissInterval, httpStatusCode } from '../shared/shared.const';
@Component({
selector: 'global-message',
templateUrl: 'message.component.html'
})
export class MessageComponent {
globalMessageOpened: boolean;
globalMessage: string;
export class MessageComponent implements OnInit {
constructor(messageService: MessageService) {
messageService.messageAnnounced$.subscribe(
message=>{
this.globalMessageOpened = true;
this.globalMessage = message;
console.log('received message:' + message);
@Input() isAppLevel: boolean;
globalMessage: Message = new Message();
globalMessageOpened: boolean;
messageText: string = "";
constructor(
private messageService: MessageService,
private router: Router,
private translate: TranslateService) { }
ngOnInit(): void {
//Only subscribe application level message
if (this.isAppLevel) {
this.messageService.appLevelAnnounced$.subscribe(
message => {
this.globalMessageOpened = true;
this.globalMessage = message;
this.messageText = message.message;
this.translateMessage(message);
}
)
} else {
//Only subscribe general messages
this.messageService.messageAnnounced$.subscribe(
message => {
this.globalMessageOpened = true;
this.globalMessage = message;
this.messageText = message.message;
this.translateMessage(message);
// Make the message alert bar dismiss after several intervals.
//Only for this case
setInterval(() => this.onClose(), dismissInterval);
}
);
}
}
//Translate or refactor the message shown to user
translateMessage(msg: Message): void {
if (!msg) {
return;
}
let key = "";
if (!msg.message) {
key = "UNKNOWN_ERROR";
} else {
key = typeof msg.message === "string" ? msg.message.trim() : msg.message;
if (key === "") {
key = "UNKNOWN_ERROR";
}
)
}
//Override key for HTTP 401 and 403
if (this.globalMessage.statusCode === httpStatusCode.Unauthorized) {
key = "UNAUTHORIZED_ERROR";
}
if (this.globalMessage.statusCode === httpStatusCode.Forbidden) {
key = "FORBIDDEN_ERROR";
}
this.translate.get(key).subscribe((res: string) => this.messageText = res);
}
public get needAuth(): boolean {
return this.globalMessage ?
(this.globalMessage.statusCode === httpStatusCode.Unauthorized) ||
(this.globalMessage.statusCode === httpStatusCode.Forbidden) : false;
}
//Show message text
public get message(): string {
return this.messageText;
}
signIn(): void {
this.router.navigate(['sign-in']);
}
onClose() {

View File

@ -1,14 +1,22 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Message } from './message';
import { AlertType } from '../shared/shared.const';
@Injectable()
export class MessageService {
private messageAnnouncedSource = new Subject<string>();
private messageAnnouncedSource = new Subject<Message>();
private appLevelAnnouncedSource = new Subject<Message>();
messageAnnounced$ = this.messageAnnouncedSource.asObservable();
appLevelAnnounced$ = this.appLevelAnnouncedSource.asObservable();
announceMessage(statusCode: number, message: string, alertType: AlertType) {
this.messageAnnouncedSource.next(Message.newMessage(statusCode, message, alertType));
}
announceMessage(message: string) {
this.messageAnnouncedSource.next(message);
announceAppLevelMessage(statusCode: number, message: string, alertType: AlertType) {
this.appLevelAnnouncedSource.next(Message.newMessage(statusCode, message, alertType));
}
}

View File

@ -0,0 +1,40 @@
import { AlertType } from '../shared/shared.const';
export class Message {
statusCode: number;
message: string;
alertType: AlertType;
isAppLevel: boolean = false;
get type(): string {
switch (this.alertType) {
case AlertType.DANGER:
return 'alert-danger';
case AlertType.INFO:
return 'alert-info';
case AlertType.SUCCESS:
return 'alert-success';
case AlertType.WARNING:
return 'alert-warning';
default:
return 'alert-warning';
}
}
constructor() { }
static newMessage(statusCode: number, message: string, alertType: AlertType): Message {
let m = new Message();
m.statusCode = statusCode;
m.message = message;
m.alertType = alertType;
return m;
}
toString(): string {
return 'Message with statusCode:' + this.statusCode +
', message:' + this.message +
', alert type:' + this.type;
}
}

View File

@ -4,16 +4,101 @@ import { RouterModule, Routes } from '@angular/router';
import { SignInComponent } from './account/sign-in/sign-in.component';
import { HarborShellComponent } from './base/harbor-shell/harbor-shell.component';
import { ProjectComponent } from './project/project.component';
import { UserComponent } from './user/user.component';
import { ReplicationManagementComponent } from './replication/replication-management/replication-management.component';
import { BaseRoutingResolver } from './base/base-routing-resolver.service';
import { TotalReplicationComponent } from './replication/total-replication/total-replication.component';
import { DestinationComponent } from './replication/destination/destination.component';
import { ProjectDetailComponent } from './project/project-detail/project-detail.component';
import { RepositoryComponent } from './repository/repository.component';
import { ReplicationComponent } from './replication/replication.component';
import { MemberComponent } from './project/member/member.component';
import { AuditLogComponent } from './log/audit-log.component';
import { BaseRoutingResolver } from './shared/route/base-routing-resolver.service';
import { ProjectRoutingResolver } from './project/project-routing-resolver.service';
import { SystemAdminGuard } from './shared/route/system-admin-activate.service';
import { SignUpComponent } from './account/sign-up/sign-up.component';
import { ResetPasswordComponent } from './account/password/reset-password.component';
import { RecentLogComponent } from './log/recent-log.component';
import { ConfigurationComponent } from './config/config.component';
import { PageNotFoundComponent } from './shared/not-found/not-found.component'
const harborRoutes: Routes = [
{ path: '', redirectTo: '/harbor', pathMatch: 'full' },
{ path: 'sign-in', component: SignInComponent },
{ path: 'sign-up', component: SignUpComponent},
{ path: 'reset_password', component: ResetPasswordComponent},
{
path: 'harbor',
component: HarborShellComponent
component: HarborShellComponent,
resolve: {
authResolver: BaseRoutingResolver
},
children: [
{
path: 'projects',
component: ProjectComponent
},
{
path: 'logs',
component: RecentLogComponent
},
{
path: 'users',
component: UserComponent,
canActivate: [SystemAdminGuard]
},
{
path: 'replications',
component: ReplicationManagementComponent,
canActivate: [SystemAdminGuard],
children: [
{
path: 'rules',
component: TotalReplicationComponent
},
{
path: 'endpoints',
component: DestinationComponent
}
]
},
{
path: 'projects/:id',
component: ProjectDetailComponent,
resolve: {
projectResolver: ProjectRoutingResolver
},
children: [
{
path: 'repository',
component: RepositoryComponent
},
{
path: 'replication',
component: ReplicationComponent
},
{
path: 'member',
component: MemberComponent
},
{
path: 'log',
component: AuditLogComponent
}
]
},
{
path: 'configs',
component: ConfigurationComponent
}
]
},
{ path: '', redirectTo: '/harbor', pathMatch: 'full' },
{ path: 'sign-in', component: SignInComponent }
{ path: "**", component: PageNotFoundComponent}
];
@NgModule({

View File

@ -0,0 +1,8 @@
import { MissingTranslationHandler, MissingTranslationHandlerParams } from '@ngx-translate/core';
export class MyMissingTranslationHandler implements MissingTranslationHandler {
handle(params: MissingTranslationHandlerParams) {
const missingText = "{Miss Harbor Text}";
return params.key || missingText;
}
}

View File

@ -2,21 +2,22 @@
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="row flex-items-xs-right">
<div class="col-xs-3 push-md-2 flex-xs-middle">
<button class="btn btn-link" (click)="toggleOptionalName(currentOption)">{{toggleName[currentOption]}}</button>
<button class="btn btn-link" (click)="toggleOptionalName(currentOption)">{{toggleName[currentOption] | translate}}</button>
</div>
<div class="col-xs-3 flex-xs-middle">
<clr-icon shape="filter" style="position: relative; left: 15px;"></clr-icon><input style="padding-left: 20px;" type="text" placeholder="Filter logs" #searchUsername (keyup.enter)="doSearchAuditLogs(searchUsername.value)">
<grid-filter filterPlaceholder='{{"AUDIT_LOG.FILTER_PLACEHOLDER" | translate}}' (filter)="doSearchAuditLogs($event)"></grid-filter>
<a href="javascript:void(0)" (click)="refresh()"><clr-icon shape="refresh"></clr-icon></a>
</div>
</div>
<div class="row flex-items-xs-right advance-option" [hidden]="currentOption === 0">
<div class="col-xs-2 push-md-1">
<clr-dropdown [clrMenuPosition]="'bottom-left'" >
<button class="btn btn-link" clrDropdownToggle>
All Operations
{{'AUDIT_LOG.ALL_OPERATIONS' | translate}}
<clr-icon shape="caret down"></clr-icon>
</button>
<div class="dropdown-menu">
<a href="javascript:void(0)" clrDropdownItem *ngFor="let f of filterOptions" (click)="toggleFilterOption(f.key)"><clr-icon shape="check" [hidden]="!f.checked"></clr-icon> {{f.description}}</a>
<a href="javascript:void(0)" clrDropdownItem *ngFor="let f of filterOptions" (click)="toggleFilterOption(f.key)"><clr-icon shape="check" [hidden]="!f.checked"></clr-icon> {{f.description | translate}}</a>
</div>
</clr-dropdown>
</div>
@ -26,11 +27,11 @@
</div>
</div>
<clr-datagrid>
<clr-dg-column>Username</clr-dg-column>
<clr-dg-column>Repository Name</clr-dg-column>
<clr-dg-column>Tag</clr-dg-column>
<clr-dg-column>Operation</clr-dg-column>
<clr-dg-column>Timestamp</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.USERNAME' | translate}}</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.REPOSITORY_NAME' | translate}}</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.TAGS' | translate}}</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.OPERATION' | translate}}</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.TIMESTAMP' | translate}}</clr-dg-column>
<clr-dg-row *ngFor="let l of auditLogs">
<clr-dg-cell>{{l.username}}</clr-dg-cell>
<clr-dg-cell>{{l.repo_name}}</clr-dg-cell>
@ -38,7 +39,7 @@
<clr-dg-cell>{{l.operation}}</clr-dg-cell>
<clr-dg-cell>{{l.op_time}}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>{{ (auditLogs ? auditLogs.length : 0) }} item(s)</clr-dg-footer>
<clr-dg-footer>{{ (auditLogs ? auditLogs.length : 0) }} {{'AUDIT_LOG.ITEMS' | translate}}</clr-dg-footer>
</clr-datagrid>
</div>
</div>

View File

@ -7,8 +7,9 @@ import { SessionUser } from '../shared/session-user';
import { AuditLogService } from './audit-log.service';
import { SessionService } from '../shared/session.service';
import { MessageService } from '../global-message/message.service';
import { AlertType } from '../shared/shared.const';
export const optionalSearch: {} = {0: 'Advanced', 1: 'Simple'};
export const optionalSearch: {} = {0: 'AUDIT_LOG.ADVANCED', 1: 'AUDIT_LOG.SIMPLE'};
export class FilterOption {
@ -42,12 +43,12 @@ export class AuditLogComponent implements OnInit {
toggleName = optionalSearch;
currentOption: number = 0;
filterOptions: FilterOption[] = [
new FilterOption('all', 'All Operations', true),
new FilterOption('pull', 'Pull', true),
new FilterOption('push', 'Push', true),
new FilterOption('create', 'Create', true),
new FilterOption('delete', 'Delete', true),
new FilterOption('others', 'Others', true)
new FilterOption('all', 'AUDIT_LOG.ALL_OPERATIONS', true),
new FilterOption('pull', 'AUDIT_LOG.PULL', true),
new FilterOption('push', 'AUDIT_LOG.PUSH', true),
new FilterOption('create', 'AUDIT_LOG.CREATE', true),
new FilterOption('delete', 'AUDIT_LOG.DELETE', true),
new FilterOption('others', 'AUDIT_LOG.OTHERS', true)
];
constructor(private route: ActivatedRoute, private router: Router, private auditLogService: AuditLogService, private messageService: MessageService) {
@ -69,7 +70,7 @@ export class AuditLogComponent implements OnInit {
response=>this.auditLogs = response,
error=>{
this.router.navigate(['/harbor', 'projects']);
this.messageService.announceMessage('Failed to list audit logs with project ID:' + queryParam.project_id);
this.messageService.announceMessage(error.status, 'Failed to list audit logs with project ID:' + queryParam.project_id, AlertType.DANGER);
}
);
}
@ -135,4 +136,7 @@ export class AuditLogComponent implements OnInit {
}
this.doSearchByOptions();
}
refresh(): void {
this.retrieve(this.queryParam);
}
}

View File

@ -10,26 +10,39 @@ import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/map';
import 'rxjs/add/observable/throw';
export const urlPrefix = '';
export const logEndpoint = "/api/logs";
@Injectable()
export class AuditLogService extends BaseService {
private httpOptions = new RequestOptions({
headers: new Headers({
"Content-Type": 'application/json',
"Accept": 'application/json'
})
});
constructor(private http: Http) {
super();
}
listAuditLogs(queryParam: AuditLog): Observable<AuditLog[]> {
return this.http
.post(urlPrefix + `/api/projects/${queryParam.project_id}/logs/filter`, {
begin_timestamp: queryParam.begin_timestamp,
end_timestamp: queryParam.end_timestamp,
keywords: queryParam.keywords,
operation: queryParam.operation,
project_id: queryParam.project_id,
username: queryParam.username })
.map(response=>response.json() as AuditLog[])
.catch(error=>this.handleError(error));
.post(`/api/projects/${queryParam.project_id}/logs/filter`, {
begin_timestamp: queryParam.begin_timestamp,
end_timestamp: queryParam.end_timestamp,
keywords: queryParam.keywords,
operation: queryParam.operation,
project_id: queryParam.project_id,
username: queryParam.username
})
.map(response => response.json() as AuditLog[])
.catch(error => this.handleError(error));
}
getRecentLogs(lines: number): Observable<AuditLog[]> {
return this.http.get(logEndpoint + "?lines=" + lines, this.httpOptions)
.map(response => response.json() as AuditLog[])
.catch(error => this.handleError(error));
}
}

View File

@ -2,10 +2,16 @@ import { NgModule } from '@angular/core';
import { AuditLogComponent } from './audit-log.component';
import { SharedModule } from '../shared/shared.module';
import { AuditLogService } from './audit-log.service';
import { RecentLogComponent } from './recent-log.component';
@NgModule({
imports: [ SharedModule ],
declarations: [ AuditLogComponent ],
providers: [ AuditLogService ],
exports: [ AuditLogComponent ]
imports: [SharedModule],
declarations: [
AuditLogComponent,
RecentLogComponent],
providers: [AuditLogService],
exports: [
AuditLogComponent,
RecentLogComponent]
})
export class LogModule {}
export class LogModule { }

View File

@ -0,0 +1,32 @@
.h2-log-override {
margin-top: 0px !important;
}
.filter-log {
float: right;
margin-right: 24px;
position: relative;
top: 8px;
}
.action-head-pos {
position: relative;
top: 20px;
}
.refresh-btn {
position: absolute;
right: -4px;
top: 8px;
cursor: pointer;
}
.custom-lines-button {
padding: 0px !important;
min-width: 25px !important;
}
.lines-button-toggole {
font-size: 16px;
text-decoration: underline;
}

View File

@ -0,0 +1,36 @@
<div>
<h2 class="h2-log-override">{{'SIDE_NAV.LOGS' | translate}}</h2>
<div class="action-head-pos">
<span>
<label>{{'RECENT_LOG.SUB_TITLE' | translate}} </label>
<button type="submit" class="btn btn-link custom-lines-button" [class.lines-button-toggole]="lines === 10" (click)="setLines(10)">10</button>
<label> | </label>
<button type="submit" class="btn btn-link custom-lines-button" [class.lines-button-toggole]="lines === 25" (click)="setLines(25)">25</button>
<label> | </label>
<button type="submit" class="btn btn-link custom-lines-button" [class.lines-button-toggole]="lines === 50" (click)="setLines(50)">50</button>
<label>{{'RECENT_LOG.SUB_TITLE_SUFIX' | translate}}</label>
</span>
<grid-filter class="filter-log" filterPlaceholder='{{"AUDIT_LOG.FILTER_PLACEHOLDER" | translate}}' (filter)="doFilter($event)"></grid-filter>
<span class="refresh-btn" (click)="refresh()">
<clr-icon shape="refresh" [hidden]="inProgress" ng-disabled="inProgress"></clr-icon>
<span class="spinner spinner-inline" [hidden]="inProgress === false"></span>
</span>
</div>
<div>
<clr-datagrid>
<clr-dg-column>{{'AUDIT_LOG.USERNAME' | translate}}</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.REPOSITORY_NAME' | translate}}</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.TAGS' | translate}}</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.OPERATION' | translate}}</clr-dg-column>
<clr-dg-column>{{'AUDIT_LOG.TIMESTAMP' | translate}}</clr-dg-column>
<clr-dg-row *ngFor="let l of recentLogs">
<clr-dg-cell>{{l.username}}</clr-dg-cell>
<clr-dg-cell>{{l.repo_name}}</clr-dg-cell>
<clr-dg-cell>{{l.repo_tag}}</clr-dg-cell>
<clr-dg-cell>{{l.operation}}</clr-dg-cell>
<clr-dg-cell>{{formatDateTime(l.op_time)}}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>{{ (recentLogs ? recentLogs.length : 0) }} {{'AUDIT_LOG.ITEMS' | translate}}</clr-dg-footer>
</clr-datagrid>
</div>
</div>

View File

@ -0,0 +1,96 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuditLog } from './audit-log';
import { SessionUser } from '../shared/session-user';
import { AuditLogService } from './audit-log.service';
import { SessionService } from '../shared/session.service';
import { MessageService } from '../global-message/message.service';
import { AlertType } from '../shared/shared.const';
import { errorHandler, accessErrorHandler } from '../shared/shared.utils';
@Component({
selector: 'recent-log',
templateUrl: './recent-log.component.html',
styleUrls: ['recent-log.component.css']
})
export class RecentLogComponent implements OnInit {
private sessionUser: SessionUser = null;
private recentLogs: AuditLog[];
private logsCache: AuditLog[];
private onGoing: boolean = false;
private lines: number = 10; //Support 10, 25 and 50
constructor(
private session: SessionService,
private msgService: MessageService,
private logService: AuditLogService) {
this.sessionUser = this.session.getCurrentUser();//Initialize session
}
ngOnInit(): void {
this.retrieveLogs();
}
public get inProgress(): boolean {
return this.onGoing;
}
public setLines(lines: number): void {
this.lines = lines;
if (this.lines < 10) {
this.lines = 10;
}
this.retrieveLogs();
}
public doFilter(terms: string): void {
if (terms.trim() === "") {
this.recentLogs = this.logsCache.filter(log => log.username != "");
return;
}
this.recentLogs = this.logsCache.filter(log => this.isMatched(terms, log));
}
public refresh(): void {
this.retrieveLogs();
}
public formatDateTime(dateTime: string){
let dt: Date = new Date(dateTime);
return dt.toLocaleString();
}
private retrieveLogs(): void {
if (this.lines < 10) {
this.lines = 10;
}
this.onGoing = true;
this.logService.getRecentLogs(this.lines)
.subscribe(
response => {
this.onGoing = false;
this.logsCache = response; //Keep the data
this.recentLogs = this.logsCache.filter(log => log.username != "");//To display
},
error => {
this.onGoing = false;
if (!accessErrorHandler(error, this.msgService)) {
this.msgService.announceMessage(error.status, errorHandler(error), AlertType.DANGER);
}
}
);
}
private isMatched(terms: string, log: AuditLog): boolean {
let reg = new RegExp('.*' + terms + '.*', 'i');
return reg.test(log.username) ||
reg.test(log.repo_name) ||
reg.test(log.operation);
}
}

View File

@ -1,11 +0,0 @@
<clr-dropdown [clrMenuPosition]="'bottom-right'" [clrCloseMenuOnItemClick]="true">
<button clrDropdownToggle>
<clr-icon shape="ellipses-vertical"></clr-icon>
</button>
<div class="dropdown-menu">
<a href="javascript:void(0)" clrDropdownItem>New Policy</a>
<a href="javascript:void(0)" clrDropdownItem (click)="toggle()">Make {{project.public === 0 ? 'Public' : 'Private'}} </a>
<div class="dropdown-divider"></div>
<a href="javascript:void(0)" clrDropdownItem (click)="delete()">Delete</a>
</div>
</clr-dropdown>

View File

@ -1,30 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Project } from '../project';
import { ProjectService } from '../project.service';
@Component({
selector: 'action-project',
templateUrl: 'action-project.component.html'
})
export class ActionProjectComponent {
@Output() togglePublic = new EventEmitter<Project>();
@Output() deleteProject = new EventEmitter<Project>();
@Input() project: Project;
constructor(private projectService: ProjectService) {}
toggle() {
if(this.project) {
this.project.public === 0 ? this.project.public = 1 : this.project.public = 0;
this.togglePublic.emit(this.project);
}
}
delete() {
if(this.project) {
this.deleteProject.emit(this.project);
}
}
}

Some files were not shown because too many files have changed in this diff Show More