From c4b2d76d1c1aee6190c57513fac23e3ed7a0640f Mon Sep 17 00:00:00 2001 From: Vitalii Shpital Date: Tue, 23 Mar 2021 22:23:27 +0200 Subject: [PATCH] web/satellite: buckets view for objects page WHAT: buckets management view for objects page WHY: to be able to create and delete buckets Change-Id: I6df986b52928433f7a0a4c4772d3064c4f1a1516 --- .../console/consoleweb/consoleapi/apikeys.go | 13 +- satellite/console/consoleweb/server.go | 2 +- satellite/console/service.go | 6 +- web/satellite/src/api/accessGrants.ts | 2 +- .../src/components/common/HeaderedInput.vue | 18 ++ .../src/components/common/HeaderlessInput.vue | 4 +- .../src/components/objects/BucketItem.vue | 153 ++++++++++ .../src/components/objects/BucketsView.vue | 269 +++++++++++++++++- .../components/objects/CreatePassphrase.vue | 9 +- .../components/objects/EnterPassphrase.vue | 9 +- .../src/components/objects/ObjectsPopup.vue | 143 ++++++++++ web/satellite/src/router/index.ts | 8 +- web/satellite/src/store/modules/objects.ts | 64 ++++- .../static/images/objects/bucket.svg | 5 + .../static/images/objects/bucketItem.svg | 3 + .../static/images/objects/delete.svg | 3 + web/satellite/static/images/objects/dots.svg | 5 + .../__snapshots__/HeaderedInput.spec.ts.snap | 3 + .../__snapshots__/CreateProject.spec.ts.snap | 2 + 19 files changed, 691 insertions(+), 30 deletions(-) create mode 100644 web/satellite/src/components/objects/BucketItem.vue create mode 100644 web/satellite/src/components/objects/ObjectsPopup.vue create mode 100644 web/satellite/static/images/objects/bucket.svg create mode 100644 web/satellite/static/images/objects/bucketItem.svg create mode 100644 web/satellite/static/images/objects/delete.svg create mode 100644 web/satellite/static/images/objects/dots.svg diff --git a/satellite/console/consoleweb/consoleapi/apikeys.go b/satellite/console/consoleweb/consoleapi/apikeys.go index 1975fca49..89684b5d9 100644 --- a/satellite/console/consoleweb/consoleapi/apikeys.go +++ b/satellite/console/consoleweb/consoleapi/apikeys.go @@ -60,6 +60,11 @@ func (keys *APIKeys) DeleteByNameAndProjectID(w http.ResponseWriter, r *http.Req return } + if console.ErrNoAPIKey.Has(err) { + keys.serveJSONError(w, http.StatusNoContent, err) + return + } + keys.serveJSONError(w, http.StatusInternalServerError, err) return } @@ -67,14 +72,18 @@ func (keys *APIKeys) DeleteByNameAndProjectID(w http.ResponseWriter, r *http.Req // serveJSONError writes JSON error to response output stream. func (keys *APIKeys) serveJSONError(w http.ResponseWriter, status int, err error) { + w.WriteHeader(status) + + if status == http.StatusNoContent { + return + } + if status == http.StatusInternalServerError { keys.log.Error("returning internal server error to client", zap.Int("code", status), zap.Error(err)) } else { keys.log.Debug("returning error to client", zap.Int("code", status), zap.Error(err)) } - w.WriteHeader(status) - var response struct { Error string `json:"error"` } diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index cffbd8e44..89ded1e96 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -272,7 +272,7 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) { cspValues := []string{ "default-src 'self'", - "connect-src 'self' api.segment.io *.google-analytics.com " + server.config.GatewayCredentialsRequestURL, + "connect-src 'self' api.segment.io *.google-analytics.com *.tardigradeshare.io " + server.config.GatewayCredentialsRequestURL, "frame-ancestors " + server.config.FrameAncestors, "frame-src 'self' *.stripe.com *.googletagmanager.com", "img-src 'self' data: *.customer.io *.googletagmanager.com *.google-analytics.com", diff --git a/satellite/console/service.go b/satellite/console/service.go index 48f8143a2..3392cb61a 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -49,6 +49,7 @@ const ( passwordIncorrectErrMsg = "Your password needs at least %d characters long" projectOwnerDeletionForbiddenErrMsg = "%s is a project owner and can not be deleted" apiKeyWithNameExistsErrMsg = "An API Key with this name already exists in this project, please use a different name" + apiKeyWithNameDoesntExistErrMsg = "An API Key with this name doesn't exist in this project." teamMemberDoesNotExistErrMsg = `There is no account on this Satellite for the user(s) you have entered. Please add team members with active accounts` @@ -74,6 +75,9 @@ var ( // ErrEmailUsed is error type that occurs on repeating auth attempts with email. ErrEmailUsed = errs.Class("email used") + + // ErrNoAPIKey is error type that occurs when there is no api key found. + ErrNoAPIKey = errs.Class("no api key found") ) // Service is handling accounts related logic. @@ -1346,7 +1350,7 @@ func (s *Service) DeleteAPIKeyByNameAndProjectID(ctx context.Context, name strin key, err := s.store.APIKeys().GetByNameAndProjectID(ctx, name, projectID) if err != nil { - return Error.Wrap(err) + return ErrNoAPIKey.New(apiKeyWithNameDoesntExistErrMsg) } err = s.store.APIKeys().Delete(ctx, key.ID) diff --git a/web/satellite/src/api/accessGrants.ts b/web/satellite/src/api/accessGrants.ts index d1e38fb0f..9cfcca28b 100644 --- a/web/satellite/src/api/accessGrants.ts +++ b/web/satellite/src/api/accessGrants.ts @@ -141,7 +141,7 @@ export class AccessGrantsApiGql extends BaseGql implements AccessGrantsApi { const path = `${this.ROOT_PATH}/delete-by-name?name=${name}&projectID=${projectID}`; const response = await this.client.delete(path); - if (response.ok) { + if (response.ok || response.status === 204) { return; } diff --git a/web/satellite/src/components/common/HeaderedInput.vue b/web/satellite/src/components/common/HeaderedInput.vue index 3008d29f6..bd7931198 100644 --- a/web/satellite/src/components/common/HeaderedInput.vue +++ b/web/satellite/src/components/common/HeaderedInput.vue @@ -9,6 +9,7 @@

{{label}}

{{additionalLabel}}

{{error}}

+

{{currentLimit}}/{{maxSymbols}}

@@ -69,6 +70,8 @@ export default class HeaderedInput extends HeaderlessInput { private readonly isLimitShown: boolean; @Prop({default: false}) private readonly isMultiline: boolean; + @Prop({default: false}) + private readonly isLoading: boolean; public value: string; @@ -160,4 +163,19 @@ export default class HeaderedInput extends HeaderlessInput { margin-left: 5px; color: rgba(56, 75, 101, 0.4); } + + .loader { + margin-left: 10px; + border: 5px solid #f3f3f3; + border-top: 5px solid #3498db; + border-radius: 50%; + width: 15px; + height: 15px; + animation: spin 2s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } diff --git a/web/satellite/src/components/common/HeaderlessInput.vue b/web/satellite/src/components/common/HeaderlessInput.vue index 433e46228..41f5b6790 100644 --- a/web/satellite/src/components/common/HeaderlessInput.vue +++ b/web/satellite/src/components/common/HeaderlessInput.vue @@ -93,8 +93,8 @@ export default class HeaderlessInput extends Vue { protected readonly error: string; @Prop({default: Number.MAX_SAFE_INTEGER}) protected readonly maxSymbols: number; - @Prop({default: []}) - protected readonly optionsList: [string]; + @Prop({default: () => []}) + protected readonly optionsList: string[]; @Prop({default: false}) protected optionsShown: boolean; @Prop({default: false}) diff --git a/web/satellite/src/components/objects/BucketItem.vue b/web/satellite/src/components/objects/BucketItem.vue new file mode 100644 index 000000000..497e3e1cb --- /dev/null +++ b/web/satellite/src/components/objects/BucketItem.vue @@ -0,0 +1,153 @@ +// Copyright (C) 2021 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/satellite/src/components/objects/BucketsView.vue b/web/satellite/src/components/objects/BucketsView.vue index 7abcb0b3e..966cd1302 100644 --- a/web/satellite/src/components/objects/BucketsView.vue +++ b/web/satellite/src/components/objects/BucketsView.vue @@ -5,40 +5,105 @@

Buckets

+
+ +

New Bucket

+
+

No Buckets

+
+
+

Name

+

Date Added

+

+

+
+ +
+
+ +
@@ -124,14 +312,18 @@ export default class BucketsView extends Vue { display: flex; flex-direction: column; align-items: center; + font-family: 'font_regular', sans-serif; + font-style: normal; + background-color: #f5f6fa; &__title-area { - margin-bottom: 100px; width: 100%; + display: flex; + justify-content: space-between; + align-items: center; &__title { font-family: 'font_medium', sans-serif; - font-style: normal; font-weight: bold; font-size: 18px; line-height: 26px; @@ -140,9 +332,33 @@ export default class BucketsView extends Vue { width: 100%; text-align: left; } + + &__button { + width: 154px; + height: 46px; + display: flex; + align-items: center; + justify-content: center; + background-color: #0068dc; + border-radius: 4px; + cursor: pointer; + + &__label { + font-weight: normal; + font-size: 12px; + line-height: 17px; + color: #fff; + margin: 0 0 0 5px; + } + + &:hover { + background-color: #0000c2; + } + } } &__loader { + margin-top: 100px; border: 16px solid #f3f3f3; border-top: 16px solid #3498db; border-radius: 50%; @@ -150,6 +366,49 @@ export default class BucketsView extends Vue { height: 120px; animation: spin 2s linear infinite; } + + &__no-buckets { + width: 100%; + text-align: center; + font-size: 30px; + line-height: 42px; + margin: 100px 0 0 0; + } + + &__list { + margin-top: 40px; + width: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 100px; + + &__sorting-header { + display: flex; + align-items: center; + padding: 0 20px 5px 20px; + width: calc(100% - 40px); + font-weight: bold; + font-size: 14px; + line-height: 20px; + color: #768394; + border-bottom: 1px solid rgba(169, 181, 193, 0.4); + + &__name { + width: calc(70% - 16px); + margin: 0; + } + + &__date { + width: 30%; + margin: 0; + } + + &__empty { + margin: 0; + } + } + } } @keyframes spin { diff --git a/web/satellite/src/components/objects/CreatePassphrase.vue b/web/satellite/src/components/objects/CreatePassphrase.vue index ec2720175..b9ccfd528 100644 --- a/web/satellite/src/components/objects/CreatePassphrase.vue +++ b/web/satellite/src/components/objects/CreatePassphrase.vue @@ -21,6 +21,7 @@ import { Component, Vue } from 'vue-property-decorator'; import GeneratePassphrase from '@/components/common/GeneratePassphrase.vue'; import { RouteConfig } from '@/router'; +import { OBJECTS_ACTIONS } from '@/store/modules/objects'; import { LocalData } from '@/utils/localData'; @Component({ @@ -57,12 +58,8 @@ export default class CreatePassphrase extends Vue { this.isLoading = false; - this.$router.push({ - name: RouteConfig.BucketsManagement.name, - params: { - passphrase: this.passphrase, - }, - }); + this.$store.dispatch(OBJECTS_ACTIONS.SET_PASSPHRASE, this.passphrase); + this.$router.push({name: RouteConfig.BucketsManagement.name}); } } diff --git a/web/satellite/src/components/objects/EnterPassphrase.vue b/web/satellite/src/components/objects/EnterPassphrase.vue index 607ee120b..2c6f11ec3 100644 --- a/web/satellite/src/components/objects/EnterPassphrase.vue +++ b/web/satellite/src/components/objects/EnterPassphrase.vue @@ -73,6 +73,7 @@ import VButton from '@/components/common/VButton.vue'; import WarningIcon from '@/../static/images/common/greyWarning.svg'; import { RouteConfig } from '@/router'; +import { OBJECTS_ACTIONS } from '@/store/modules/objects'; import { LocalData, UserIDPassSalt } from '@/utils/localData'; import { MetaUtils } from '@/utils/meta'; @@ -115,12 +116,8 @@ export default class EnterPassphrase extends Vue { switch (true) { case areHashesEqual() || !areHashesEqual() && this.isError && this.isCheckboxChecked: - this.$router.push({ - name: RouteConfig.BucketsManagement.name, - params: { - passphrase: this.passphrase, - }, - }); + this.$store.dispatch(OBJECTS_ACTIONS.SET_PASSPHRASE, this.passphrase); + this.$router.push({name: RouteConfig.BucketsManagement.name}); return; case !areHashesEqual() && this.isError && !this.isCheckboxChecked: diff --git a/web/satellite/src/components/objects/ObjectsPopup.vue b/web/satellite/src/components/objects/ObjectsPopup.vue new file mode 100644 index 000000000..8df7fd397 --- /dev/null +++ b/web/satellite/src/components/objects/ObjectsPopup.vue @@ -0,0 +1,143 @@ +// Copyright (C) 2021 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/satellite/src/router/index.ts b/web/satellite/src/router/index.ts index 46dbf7dd2..194e23399 100644 --- a/web/satellite/src/router/index.ts +++ b/web/satellite/src/router/index.ts @@ -92,7 +92,7 @@ export abstract class RouteConfig { public static CreatePassphrase = new NavigationLink('create-passphrase', 'Objects Create Passphrase'); public static EnterPassphrase = new NavigationLink('enter-passphrase', 'Objects Enter Passphrase'); public static BucketsManagement = new NavigationLink('buckets', 'Buckets Management'); - public static UploadFile = new NavigationLink('upload', 'Objects Upload'); + public static UploadFile = new NavigationLink('upload/', 'Objects Upload'); } export const notProjectRelatedRoutes = [ @@ -320,6 +320,12 @@ export const router = new Router({ path: RouteConfig.UploadFile.path, name: RouteConfig.UploadFile.name, component: UploadFile, + children: [ + { + path: '*', + component: UploadFile, + }, + ], }, ], }, diff --git a/web/satellite/src/store/modules/objects.ts b/web/satellite/src/store/modules/objects.ts index 282267857..88aef490a 100644 --- a/web/satellite/src/store/modules/objects.ts +++ b/web/satellite/src/store/modules/objects.ts @@ -3,13 +3,24 @@ import { StoreModule } from '@/store'; import { GatewayCredentials } from '@/types/accessGrants'; -import * as AWS from '@aws-sdk/client-s3'; +import { + Bucket, + CreateBucketCommand, + DeleteBucketCommand, + ListBucketsCommand, + ListBucketsOutput, + S3Client, +} from '@aws-sdk/client-s3'; export const OBJECTS_ACTIONS = { CLEAR: 'clearObjects', SET_GATEWAY_CREDENTIALS: 'setGatewayCredentials', SET_ACCESS_GRANT: 'setAccessGrant', SET_S3_CLIENT: 'setS3Client', + SET_PASSPHRASE: 'setPassphrase', + FETCH_BUCKETS: 'fetchBuckets', + CREATE_BUCKET: 'createBucket', + DELETE_BUCKET: 'deleteBucket', }; export const OBJECTS_MUTATIONS = { @@ -17,6 +28,8 @@ export const OBJECTS_MUTATIONS = { SET_ACCESS_GRANT: 'setAccessGrant', CLEAR: 'clearObjects', SET_S3_CLIENT: 'setS3Client', + SET_BUCKETS: 'setBuckets', + SET_PASSPHRASE: 'setPassphrase', }; const { @@ -24,12 +37,16 @@ const { SET_ACCESS_GRANT, SET_GATEWAY_CREDENTIALS, SET_S3_CLIENT, + SET_BUCKETS, + SET_PASSPHRASE, } = OBJECTS_MUTATIONS; export class ObjectsState { public accessGrant: string = ''; public gatewayCredentials: GatewayCredentials = new GatewayCredentials(); - public s3Client: AWS.S3 = new AWS.S3({}); + public s3Client: S3Client = new S3Client({}); + public buckets: Bucket[] = []; + public passphrase: string = ''; } /** @@ -46,15 +63,29 @@ export function makeObjectsModule(): StoreModule { state.gatewayCredentials = credentials; }, [SET_S3_CLIENT](state: ObjectsState) { + // TODO: use this for local testing. Remove after final implementation. + // state.gatewayCredentials.accessKeyId = 'jwitszrc76z4amjcrinv4zjpnlia'; + // state.gatewayCredentials.secretKey = 'jyjufay7ddmwj6tlboyuj23yy4lqigfqa2ie25y526qmjj65khxzw'; + // state.gatewayCredentials.endpoint = 'https://gateway.tardigradeshare.io'; + const s3Config = { - accessKeyId: state.gatewayCredentials.accessKeyId, - secretAccessKey: state.gatewayCredentials.secretKey, + credentials: { + accessKeyId: state.gatewayCredentials.accessKeyId, + secretAccessKey: state.gatewayCredentials.secretKey, + }, endpoint: state.gatewayCredentials.endpoint, s3ForcePathStyle: true, signatureVersion: 'v4', + region: 'REGION', }; - state.s3Client = new AWS.S3(s3Config); + state.s3Client = new S3Client(s3Config); + }, + [SET_BUCKETS](state: ObjectsState, buckets: Bucket[]) { + state.buckets = buckets; + }, + [SET_PASSPHRASE](state: ObjectsState, passphrase: string) { + state.passphrase = passphrase; }, [CLEAR](state: ObjectsState) { state.accessGrant = ''; @@ -71,6 +102,29 @@ export function makeObjectsModule(): StoreModule { setS3Client: function({commit}: any): void { commit(SET_S3_CLIENT); }, + setPassphrase: function({commit}: any, passphrase: string): void { + commit(SET_PASSPHRASE, passphrase); + }, + fetchBuckets: async function(ctx): Promise { + const bucketsOutput: ListBucketsOutput = await ctx.state.s3Client.send(new ListBucketsCommand({ + credentials: { + accessKeyId: ctx.state.gatewayCredentials.accessKeyId, + secretAccessKey: ctx.state.gatewayCredentials.secretKey, + }, + })); + + ctx.commit(SET_BUCKETS, bucketsOutput.Buckets); + }, + createBucket: async function(ctx, name: string): Promise { + await ctx.state.s3Client.send(new CreateBucketCommand({ + Bucket: name, + })); + }, + deleteBucket: async function(ctx, name: string): Promise { + await ctx.state.s3Client.send(new DeleteBucketCommand({ + Bucket: name, + })); + }, clearObjects: function ({commit}: any): void { commit(CLEAR); }, diff --git a/web/satellite/static/images/objects/bucket.svg b/web/satellite/static/images/objects/bucket.svg new file mode 100644 index 000000000..9cf2a8f0f --- /dev/null +++ b/web/satellite/static/images/objects/bucket.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/satellite/static/images/objects/bucketItem.svg b/web/satellite/static/images/objects/bucketItem.svg new file mode 100644 index 000000000..85b24a6d0 --- /dev/null +++ b/web/satellite/static/images/objects/bucketItem.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/satellite/static/images/objects/delete.svg b/web/satellite/static/images/objects/delete.svg new file mode 100644 index 000000000..b7eb68b6c --- /dev/null +++ b/web/satellite/static/images/objects/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/satellite/static/images/objects/dots.svg b/web/satellite/static/images/objects/dots.svg new file mode 100644 index 000000000..9439af96f --- /dev/null +++ b/web/satellite/static/images/objects/dots.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/satellite/tests/unit/common/__snapshots__/HeaderedInput.spec.ts.snap b/web/satellite/tests/unit/common/__snapshots__/HeaderedInput.spec.ts.snap index 0f3d98eca..36ee9b462 100644 --- a/web/satellite/tests/unit/common/__snapshots__/HeaderedInput.spec.ts.snap +++ b/web/satellite/tests/unit/common/__snapshots__/HeaderedInput.spec.ts.snap @@ -8,6 +8,7 @@ exports[`HeaderedInput.vue renders correctly with default props 1`] = `

+
@@ -24,6 +25,7 @@ exports[`HeaderedInput.vue renders correctly with input error 1`] = `

testError

+ @@ -40,6 +42,7 @@ exports[`HeaderedInput.vue renders correctly with isMultiline props 1`] = `

+ diff --git a/web/satellite/tests/unit/project/__snapshots__/CreateProject.spec.ts.snap b/web/satellite/tests/unit/project/__snapshots__/CreateProject.spec.ts.snap index a91a6f41b..c3b602886 100644 --- a/web/satellite/tests/unit/project/__snapshots__/CreateProject.spec.ts.snap +++ b/web/satellite/tests/unit/project/__snapshots__/CreateProject.spec.ts.snap @@ -11,6 +11,7 @@ exports[`CreateProject.vue renders correctly 1`] = `

Project Name

Up To 20 Characters

+

0/20

@@ -24,6 +25,7 @@ exports[`CreateProject.vue renders correctly 1`] = `

Description

Optional

+

0/100