web/satellite: import file browser component
WHAT: import and instantiate file browser component WHY: to operate over folders and objects Change-Id: Ib6fb4fdc2668d2f274df3d1b23f8cc0bb6a361ea
This commit is contained in:
parent
444b1f4757
commit
6ae2351389
@ -88,6 +88,7 @@ type Config struct {
|
|||||||
BetaSatelliteSupportURL string `help:"url link for for beta satellite support" default:""`
|
BetaSatelliteSupportURL string `help:"url link for for beta satellite support" default:""`
|
||||||
DocumentationURL string `help:"url link to documentation" devDefault:"https://documentation.storj.io/" releaseDefault:"https://documentation.tardigrade.io/"`
|
DocumentationURL string `help:"url link to documentation" devDefault:"https://documentation.storj.io/" releaseDefault:"https://documentation.tardigrade.io/"`
|
||||||
CouponCodeUIEnabled bool `help:"indicates if user is allowed to add coupon codes to account" default:"false"`
|
CouponCodeUIEnabled bool `help:"indicates if user is allowed to add coupon codes to account" default:"false"`
|
||||||
|
FileBrowserFlowDisabled bool `help:"indicates if file browser flow is disabled" default:"true"`
|
||||||
|
|
||||||
RateLimit web.IPRateLimiterConfig
|
RateLimit web.IPRateLimiterConfig
|
||||||
|
|
||||||
@ -311,6 +312,7 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
BetaSatelliteSupportURL string
|
BetaSatelliteSupportURL string
|
||||||
DocumentationURL string
|
DocumentationURL string
|
||||||
CouponCodeUIEnabled bool
|
CouponCodeUIEnabled bool
|
||||||
|
FileBrowserFlowDisabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
data.ExternalAddress = server.config.ExternalAddress
|
data.ExternalAddress = server.config.ExternalAddress
|
||||||
@ -330,6 +332,7 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
data.BetaSatelliteSupportURL = server.config.BetaSatelliteSupportURL
|
data.BetaSatelliteSupportURL = server.config.BetaSatelliteSupportURL
|
||||||
data.DocumentationURL = server.config.DocumentationURL
|
data.DocumentationURL = server.config.DocumentationURL
|
||||||
data.CouponCodeUIEnabled = server.config.CouponCodeUIEnabled
|
data.CouponCodeUIEnabled = server.config.CouponCodeUIEnabled
|
||||||
|
data.FileBrowserFlowDisabled = server.config.FileBrowserFlowDisabled
|
||||||
|
|
||||||
if server.templates.index == nil {
|
if server.templates.index == nil {
|
||||||
server.log.Error("index template is not set")
|
server.log.Error("index template is not set")
|
||||||
|
3
scripts/testdata/satellite-config.yaml.lock
vendored
3
scripts/testdata/satellite-config.yaml.lock
vendored
@ -103,6 +103,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
|
|||||||
# external endpoint of the satellite if hosted
|
# external endpoint of the satellite if hosted
|
||||||
# console.external-address: ""
|
# console.external-address: ""
|
||||||
|
|
||||||
|
# indicates if file browser flow is disabled
|
||||||
|
# console.file-browser-flow-disabled: true
|
||||||
|
|
||||||
# allow domains to embed the satellite in a frame, space separated
|
# allow domains to embed the satellite in a frame, space separated
|
||||||
# console.frame-ancestors: tardigrade.io
|
# console.frame-ancestors: tardigrade.io
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
<meta name="beta-satellite-support-url" content="{{ .BetaSatelliteSupportURL }}">
|
<meta name="beta-satellite-support-url" content="{{ .BetaSatelliteSupportURL }}">
|
||||||
<meta name="documentation-url" content="{{ .DocumentationURL }}">
|
<meta name="documentation-url" content="{{ .DocumentationURL }}">
|
||||||
<meta name="coupon-code-ui-enabled" content="{{ .CouponCodeUIEnabled }}">
|
<meta name="coupon-code-ui-enabled" content="{{ .CouponCodeUIEnabled }}">
|
||||||
|
<meta name="file-browser-flow-disabled" content="{{ .FileBrowserFlowDisabled }}">
|
||||||
<title>{{ .SatelliteName }}</title>
|
<title>{{ .SatelliteName }}</title>
|
||||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAL8SURBVHgBxVcxTFNRFL01JvylrRsmNWETWWSybg5I3NTQBCdRFyfrDuoKMgMyOQgdTcDAJmrUrWWCgQIbhJKy0TJQpsc73P/S9/tfP+8/GnqSn1fo77/3nnPufe8nhAR1ETeoy7hJjpj93/ycu0+UuUVuEA5Y2xaifirEVpX/nvknnBFbgpMGUfmIKOkRLRT578oxXy6IJcFCialH0EyaaPoZBy7tEQ3NEY1IKd4/iidHwqYLijLA559cuY6dT0RjBU5AAYm9fiivLFnBKMGBTyeqQ4BXhXDwdqjUiKZkskOzREsbzeeBNRMCEiDgr12uYl1WNbnW/oc2iUys8jrQyyxhHRkM3hdgAMFBHQyGG/GDqyDlsSeS/npQC99jlEBpOnyX2XCF8sGhZLbeMLMZkCDbJ1nYYTfDeMP9fMH5y5vmIKYE8RxUjBXPedDH1Zu6I9QFSzLQxErz4Xn5oNwg+2NSmuv3Lkvz4QlTi8rupDlBmA6tqQLrnYNCvoxSNAOtUEaakwzMv+ALidTP2OlKKiSK75Cs6hy9NYFkjzmG1SBCIuUq0Za8pgydge8R9E+e10qNrGE1ikH5435mo11bQgr4B9LEgVUC0Npm1o+vcuvBxB1NYFsaaeC2XUuW/Xs7msC9Xqa+MMa9jQr1KtXAQoKYHakeskbIhDrVasdTbbVY4s8ZYld/9PWuyeTSHksFBjBFcZ+aH/j/yZk5gcAcgImgIX6MNsKKhKBta1sB2A3HV5pD6iJQIzw/MICwoohc1F6ALBH03XemFYPl+VdzcBNUh6j5gZZEcP341opAAnX/AXl/A0FlrrshgMRR+YUvPPN8CHgAxlqWVYuEdH7V/ZilA6cosFDa53EcmUDKC+7X+IwxHEVhO0DK6aeXH88uHcWQA8xE7Yg69M6xgdWZUEFtNNDyx1s2KnyDIxu22zdZTjgWhANm/vL6clGIsnw3+Fbk94RreS8AMGrBxvwoT0lMPnSNC2JJoAPdgnMBJLjKq5lzAp1C19+OzwFiYzAU5f7eeQAAAABJRU5ErkJggg==" type="image/x-icon">
|
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAL8SURBVHgBxVcxTFNRFL01JvylrRsmNWETWWSybg5I3NTQBCdRFyfrDuoKMgMyOQgdTcDAJmrUrWWCgQIbhJKy0TJQpsc73P/S9/tfP+8/GnqSn1fo77/3nnPufe8nhAR1ETeoy7hJjpj93/ycu0+UuUVuEA5Y2xaifirEVpX/nvknnBFbgpMGUfmIKOkRLRT578oxXy6IJcFCialH0EyaaPoZBy7tEQ3NEY1IKd4/iidHwqYLijLA559cuY6dT0RjBU5AAYm9fiivLFnBKMGBTyeqQ4BXhXDwdqjUiKZkskOzREsbzeeBNRMCEiDgr12uYl1WNbnW/oc2iUys8jrQyyxhHRkM3hdgAMFBHQyGG/GDqyDlsSeS/npQC99jlEBpOnyX2XCF8sGhZLbeMLMZkCDbJ1nYYTfDeMP9fMH5y5vmIKYE8RxUjBXPedDH1Zu6I9QFSzLQxErz4Xn5oNwg+2NSmuv3Lkvz4QlTi8rupDlBmA6tqQLrnYNCvoxSNAOtUEaakwzMv+ALidTP2OlKKiSK75Cs6hy9NYFkjzmG1SBCIuUq0Za8pgydge8R9E+e10qNrGE1ikH5435mo11bQgr4B9LEgVUC0Npm1o+vcuvBxB1NYFsaaeC2XUuW/Xs7msC9Xqa+MMa9jQr1KtXAQoKYHakeskbIhDrVasdTbbVY4s8ZYld/9PWuyeTSHksFBjBFcZ+aH/j/yZk5gcAcgImgIX6MNsKKhKBta1sB2A3HV5pD6iJQIzw/MICwoohc1F6ALBH03XemFYPl+VdzcBNUh6j5gZZEcP341opAAnX/AXl/A0FlrrshgMRR+YUvPPN8CHgAxlqWVYuEdH7V/ZilA6cosFDa53EcmUDKC+7X+IwxHEVhO0DK6aeXH88uHcWQA8xE7Yg69M6xgdWZUEFtNNDyx1s2KnyDIxu22zdZTjgWhANm/vL6clGIsnw3+Fbk94RreS8AMGrBxvwoT0lMPnSNC2JJoAPdgnMBJLjKq5lzAp1C19+OzwFiYzAU5f7eeQAAAABJRU5ErkJggg==" type="image/x-icon">
|
||||||
<link rel="dns-prefetch" href="https://js.stripe.com">
|
<link rel="dns-prefetch" href="https://js.stripe.com">
|
||||||
|
4100
web/satellite/package-lock.json
generated
4100
web/satellite/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,8 @@
|
|||||||
"dev": "vue-cli-service build --mode development"
|
"dev": "vue-cli-service build --mode development"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.8.1",
|
"aws-sdk": "2.853.0",
|
||||||
|
"browser": "git+https://github.com/storj/browser#7e16121e9070e671e3c68557048ced234c61943b",
|
||||||
"apollo-cache-inmemory": "1.6.6",
|
"apollo-cache-inmemory": "1.6.6",
|
||||||
"apollo-client": "2.6.10",
|
"apollo-client": "2.6.10",
|
||||||
"apollo-link": "1.2.14",
|
"apollo-link": "1.2.14",
|
||||||
|
@ -157,16 +157,17 @@ export class AccessGrantsApiGql extends BaseGql implements AccessGrantsApi {
|
|||||||
*
|
*
|
||||||
* @param accessGrant - generated access grant
|
* @param accessGrant - generated access grant
|
||||||
* @param optionalURL - optional requestURL
|
* @param optionalURL - optional requestURL
|
||||||
|
* @param isPublic - optional status
|
||||||
* @throws Error
|
* @throws Error
|
||||||
*/
|
*/
|
||||||
public async getGatewayCredentials(accessGrant: string, optionalURL?: string): Promise<GatewayCredentials> {
|
public async getGatewayCredentials(accessGrant: string, optionalURL?: string, isPublic?: boolean): Promise<GatewayCredentials> {
|
||||||
const requestURL: string = optionalURL || MetaUtils.getMetaContent('gateway-credentials-request-url');
|
const requestURL: string = optionalURL || MetaUtils.getMetaContent('gateway-credentials-request-url');
|
||||||
if (!requestURL) throw new Error('Cannot get gateway credentials: request URL is not provided');
|
if (!requestURL) throw new Error('Cannot get gateway credentials: request URL is not provided');
|
||||||
|
|
||||||
const path = `${requestURL}/v1/access`;
|
const path = `${requestURL}/v1/access`;
|
||||||
const body = {
|
const body = {
|
||||||
access_grant: accessGrant,
|
access_grant: accessGrant,
|
||||||
public: false,
|
public: isPublic || false,
|
||||||
};
|
};
|
||||||
const response = await this.client.post(path, JSON.stringify(body));
|
const response = await this.client.post(path, JSON.stringify(body));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
@ -173,6 +173,7 @@ export default class ConfirmDeletePopup extends Vue {
|
|||||||
&__item {
|
&__item {
|
||||||
padding: 25px;
|
padding: 25px;
|
||||||
width: calc(100% - 50px);
|
width: calc(100% - 50px);
|
||||||
|
max-width: calc(100% - 50px);
|
||||||
background: rgba(245, 246, 250, 0.6);
|
background: rgba(245, 246, 250, 0.6);
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
@ -182,6 +183,7 @@ export default class ConfirmDeletePopup extends Vue {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 19px;
|
line-height: 19px;
|
||||||
color: #1b2533;
|
color: #1b2533;
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ import { AuthHttpApi } from '@/api/auth';
|
|||||||
import { RouteConfig } from '@/router';
|
import { RouteConfig } from '@/router';
|
||||||
import { ACCESS_GRANTS_ACTIONS } from '@/store/modules/accessGrants';
|
import { ACCESS_GRANTS_ACTIONS } from '@/store/modules/accessGrants';
|
||||||
import { BUCKET_ACTIONS } from '@/store/modules/buckets';
|
import { BUCKET_ACTIONS } from '@/store/modules/buckets';
|
||||||
|
import { OBJECTS_ACTIONS } from '@/store/modules/objects';
|
||||||
import { PROJECTS_ACTIONS } from '@/store/modules/projects';
|
import { PROJECTS_ACTIONS } from '@/store/modules/projects';
|
||||||
import { USER_ACTIONS } from '@/store/modules/users';
|
import { USER_ACTIONS } from '@/store/modules/users';
|
||||||
import {
|
import {
|
||||||
@ -63,6 +64,7 @@ export default class AccountDropdown extends Vue {
|
|||||||
await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.STOP_ACCESS_GRANTS_WEB_WORKER);
|
await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.STOP_ACCESS_GRANTS_WEB_WORKER);
|
||||||
await this.$store.dispatch(NOTIFICATION_ACTIONS.CLEAR);
|
await this.$store.dispatch(NOTIFICATION_ACTIONS.CLEAR);
|
||||||
await this.$store.dispatch(BUCKET_ACTIONS.CLEAR);
|
await this.$store.dispatch(BUCKET_ACTIONS.CLEAR);
|
||||||
|
await this.$store.dispatch(OBJECTS_ACTIONS.CLEAR);
|
||||||
await this.$store.dispatch(APP_STATE_ACTIONS.CLOSE_POPUPS);
|
await this.$store.dispatch(APP_STATE_ACTIONS.CLOSE_POPUPS);
|
||||||
|
|
||||||
LocalData.removeUserId();
|
LocalData.removeUserId();
|
||||||
|
@ -31,6 +31,7 @@ import TeamIcon from '@/../static/images/navigation/team.svg';
|
|||||||
|
|
||||||
import { RouteConfig } from '@/router';
|
import { RouteConfig } from '@/router';
|
||||||
import { NavigationLink } from '@/types/navigation';
|
import { NavigationLink } from '@/types/navigation';
|
||||||
|
import { MetaUtils } from '@/utils/meta';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
@ -42,16 +43,30 @@ import { NavigationLink } from '@/types/navigation';
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class NavigationArea extends Vue {
|
export default class NavigationArea extends Vue {
|
||||||
|
public navigation: NavigationLink[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Array of navigation links with icons.
|
* Lifecycle hook before initial render.
|
||||||
|
* Sets navigation side bar list.
|
||||||
*/
|
*/
|
||||||
public readonly navigation: NavigationLink[] = [
|
public async beforeMount(): Promise<void> {
|
||||||
RouteConfig.ProjectDashboard.withIcon(DashboardIcon),
|
if (await JSON.parse(MetaUtils.getMetaContent('file-browser-flow-disabled'))) {
|
||||||
// TODO: enable when the flow will be finished
|
this.navigation = [
|
||||||
// RouteConfig.Objects.withIcon(ObjectsIcon),
|
RouteConfig.ProjectDashboard.withIcon(DashboardIcon),
|
||||||
RouteConfig.AccessGrants.withIcon(AccessGrantsIcon),
|
RouteConfig.AccessGrants.withIcon(AccessGrantsIcon),
|
||||||
RouteConfig.Users.withIcon(TeamIcon),
|
RouteConfig.Users.withIcon(TeamIcon),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.navigation = [
|
||||||
|
RouteConfig.ProjectDashboard.withIcon(DashboardIcon),
|
||||||
|
RouteConfig.Objects.withIcon(ObjectsIcon),
|
||||||
|
RouteConfig.AccessGrants.withIcon(AccessGrantsIcon),
|
||||||
|
RouteConfig.Users.withIcon(TeamIcon),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates if navigation side bar is hidden.
|
* Indicates if navigation side bar is hidden.
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Bucket } from 'aws-sdk/clients/s3';
|
||||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||||
|
|
||||||
import ObjectsPopup from '@/components/objects/ObjectsPopup.vue';
|
import ObjectsPopup from '@/components/objects/ObjectsPopup.vue';
|
||||||
@ -29,8 +30,6 @@ import BucketIcon from '@/../static/images/objects/bucketItem.svg';
|
|||||||
import DeleteIcon from '@/../static/images/objects/delete.svg';
|
import DeleteIcon from '@/../static/images/objects/delete.svg';
|
||||||
import DotsIcon from '@/../static/images/objects/dots.svg';
|
import DotsIcon from '@/../static/images/objects/dots.svg';
|
||||||
|
|
||||||
import { Bucket } from '@aws-sdk/client-s3';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
BucketIcon,
|
BucketIcon,
|
||||||
|
@ -11,14 +11,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="buckets-view__loader" v-if="isLoading"/>
|
<div class="buckets-view__loader" v-if="isLoading"/>
|
||||||
<p class="buckets-view__no-buckets" v-if="!(isLoading || buckets.length)">No Buckets</p>
|
<p class="buckets-view__no-buckets" v-if="!(isLoading || bucketsList.length)">No Buckets</p>
|
||||||
<div class="buckets-view__list" v-if="!isLoading && buckets.length">
|
<div class="buckets-view__list" v-if="!isLoading && bucketsList.length">
|
||||||
<div class="buckets-view__list__sorting-header">
|
<div class="buckets-view__list__sorting-header">
|
||||||
<p class="buckets-view__list__sorting-header__name">Name</p>
|
<p class="buckets-view__list__sorting-header__name">Name</p>
|
||||||
<p class="buckets-view__list__sorting-header__date">Date Added</p>
|
<p class="buckets-view__list__sorting-header__date">Date Added</p>
|
||||||
<p class="buckets-view__list__sorting-header__empty"/>
|
<p class="buckets-view__list__sorting-header__empty"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="buckets-view__list__item" v-for="(bucket, key) in buckets" :key="key" @click.stop="openBucket">
|
<div class="buckets-view__list__item" v-for="(bucket, key) in bucketsList" :key="key" @click.stop="openBucket(bucket.Name)">
|
||||||
<BucketItem
|
<BucketItem
|
||||||
:item-data="bucket"
|
:item-data="bucket"
|
||||||
:show-delete-bucket-popup="showDeleteBucketPopup"
|
:show-delete-bucket-popup="showDeleteBucketPopup"
|
||||||
@ -55,6 +55,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Bucket } from 'aws-sdk/clients/s3';
|
||||||
import { Component, Vue } from 'vue-property-decorator';
|
import { Component, Vue } from 'vue-property-decorator';
|
||||||
|
|
||||||
import BucketItem from '@/components/objects/BucketItem.vue';
|
import BucketItem from '@/components/objects/BucketItem.vue';
|
||||||
@ -67,7 +68,6 @@ import { ACCESS_GRANTS_ACTIONS } from '@/store/modules/accessGrants';
|
|||||||
import { OBJECTS_ACTIONS } from '@/store/modules/objects';
|
import { OBJECTS_ACTIONS } from '@/store/modules/objects';
|
||||||
import { AccessGrant, GatewayCredentials } from '@/types/accessGrants';
|
import { AccessGrant, GatewayCredentials } from '@/types/accessGrants';
|
||||||
import { MetaUtils } from '@/utils/meta';
|
import { MetaUtils } from '@/utils/meta';
|
||||||
import { Bucket } from '@aws-sdk/client-s3';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
@ -102,85 +102,88 @@ export default class BucketsView extends Vue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeTemporaryAccessGrant();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cleanAPIKey: AccessGrant = await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.CREATE, this.FILE_BROWSER_AG_NAME);
|
if (!this.accessGrantFromStore) {
|
||||||
|
await this.setWorker();
|
||||||
|
// This is done just in case old temporary access grant still exists.
|
||||||
|
await this.removeTemporaryAccessGrant();
|
||||||
|
await this.setAccess();
|
||||||
|
await this.fetchBuckets();
|
||||||
|
}
|
||||||
|
|
||||||
this.worker = this.$store.state.accessGrantsModule.accessGrantsWebWorker;
|
this.isLoading = false;
|
||||||
this.worker.onmessage = (event: MessageEvent) => {
|
|
||||||
const data = event.data;
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.grantWithPermissions = data.value;
|
if (!this.bucketsList.length) this.showCreateBucketPopup();
|
||||||
};
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const inADay = new Date(now.setDate(now.getDate() + 1));
|
|
||||||
|
|
||||||
await this.worker.postMessage({
|
|
||||||
'type': 'SetPermission',
|
|
||||||
'isDownload': true,
|
|
||||||
'isUpload': true,
|
|
||||||
'isList': true,
|
|
||||||
'isDelete': true,
|
|
||||||
'buckets': [],
|
|
||||||
'apiKey': cleanAPIKey.secret,
|
|
||||||
'notBefore': now.toISOString(),
|
|
||||||
'notAfter': inADay.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Timeout is used to give some time for web worker to return value.
|
|
||||||
setTimeout(() => {
|
|
||||||
this.worker.onmessage = (event: MessageEvent) => {
|
|
||||||
const data = event.data;
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.accessGrant = data.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const satelliteNodeURL: string = MetaUtils.getMetaContent('satellite-nodeurl');
|
|
||||||
this.worker.postMessage({
|
|
||||||
'type': 'GenerateAccess',
|
|
||||||
'apiKey': this.grantWithPermissions,
|
|
||||||
'passphrase': this.$route.params.passphrase,
|
|
||||||
'projectID': this.$store.getters.selectedProject.id,
|
|
||||||
'satelliteNodeURL': satelliteNodeURL,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Timeout is used to give some time for web worker to return value.
|
|
||||||
setTimeout(async () => {
|
|
||||||
await this.$store.dispatch(OBJECTS_ACTIONS.SET_ACCESS_GRANT, this.accessGrant);
|
|
||||||
|
|
||||||
// TODO: use this value until all the satellites will have this URL set.
|
|
||||||
const gatewayURL = 'https://auth.tardigradeshare.io';
|
|
||||||
const gatewayCredentials: GatewayCredentials = await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.GET_GATEWAY_CREDENTIALS, {accessGrant: this.accessGrant, optionalURL: gatewayURL});
|
|
||||||
await this.$store.dispatch(OBJECTS_ACTIONS.SET_GATEWAY_CREDENTIALS, gatewayCredentials);
|
|
||||||
await this.$store.dispatch(OBJECTS_ACTIONS.SET_S3_CLIENT);
|
|
||||||
await this.$store.dispatch(OBJECTS_ACTIONS.FETCH_BUCKETS);
|
|
||||||
|
|
||||||
this.isLoading = false;
|
|
||||||
|
|
||||||
if (!this.buckets.length) this.showCreateBucketPopup();
|
|
||||||
}, 1000);
|
|
||||||
}, 1000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.$notify.error(`Failed to setup Objects view. ${error.message}`);
|
await this.$notify.error(`Failed to setup Buckets view. ${error.message}`);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle hook before component destroying.
|
* Sets access to S3 client.
|
||||||
* Remove temporary created access grant.
|
|
||||||
*/
|
*/
|
||||||
public async beforeDestroy(): Promise<void> {
|
public async setAccess(): Promise<void> {
|
||||||
await this.removeTemporaryAccessGrant();
|
const cleanAPIKey: AccessGrant = await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.CREATE, this.FILE_BROWSER_AG_NAME);
|
||||||
|
await this.$store.dispatch(OBJECTS_ACTIONS.SET_API_KEY, cleanAPIKey.secret);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const inADay = new Date(now.setDate(now.getDate() + 1));
|
||||||
|
|
||||||
|
await this.worker.postMessage({
|
||||||
|
'type': 'SetPermission',
|
||||||
|
'isDownload': true,
|
||||||
|
'isUpload': true,
|
||||||
|
'isList': true,
|
||||||
|
'isDelete': true,
|
||||||
|
'buckets': [],
|
||||||
|
'apiKey': cleanAPIKey.secret,
|
||||||
|
'notBefore': new Date().toISOString(),
|
||||||
|
'notAfter': inADay.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const grantEvent: MessageEvent = await new Promise(resolve => this.worker.onmessage = resolve);
|
||||||
|
this.grantWithPermissions = grantEvent.data.value;
|
||||||
|
if (grantEvent.data.error) {
|
||||||
|
throw new Error(grantEvent.data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const satelliteNodeURL: string = MetaUtils.getMetaContent('satellite-nodeurl');
|
||||||
|
this.worker.postMessage({
|
||||||
|
'type': 'GenerateAccess',
|
||||||
|
'apiKey': this.grantWithPermissions,
|
||||||
|
'passphrase': this.passphrase,
|
||||||
|
'projectID': this.$store.getters.selectedProject.id,
|
||||||
|
'satelliteNodeURL': satelliteNodeURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessGrantEvent: MessageEvent = await new Promise(resolve => this.worker.onmessage = resolve);
|
||||||
|
this.accessGrant = accessGrantEvent.data.value;
|
||||||
|
if (accessGrantEvent.data.error) {
|
||||||
|
throw new Error(accessGrantEvent.data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$store.dispatch(OBJECTS_ACTIONS.SET_ACCESS_GRANT, this.accessGrant);
|
||||||
|
|
||||||
|
const gatewayCredentials: GatewayCredentials = await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.GET_GATEWAY_CREDENTIALS, {accessGrant: this.accessGrant});
|
||||||
|
await this.$store.dispatch(OBJECTS_ACTIONS.SET_GATEWAY_CREDENTIALS, gatewayCredentials);
|
||||||
|
await this.$store.dispatch(OBJECTS_ACTIONS.SET_S3_CLIENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches bucket using S3 client.
|
||||||
|
*/
|
||||||
|
public async fetchBuckets(): Promise<void> {
|
||||||
|
await this.$store.dispatch(OBJECTS_ACTIONS.FETCH_BUCKETS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets local worker with worker instantiated in store.
|
||||||
|
*/
|
||||||
|
public setWorker(): void {
|
||||||
|
this.worker = this.$store.state.accessGrantsModule.accessGrantsWebWorker;
|
||||||
|
this.worker.onerror = (error: ErrorEvent) => {
|
||||||
|
this.$notify.error(error.message);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -235,6 +238,12 @@ export default class BucketsView extends Vue {
|
|||||||
* Opens utils dropdown.
|
* Opens utils dropdown.
|
||||||
*/
|
*/
|
||||||
public openDropdown(key: number): void {
|
public openDropdown(key: number): void {
|
||||||
|
if (this.activeDropdown === key) {
|
||||||
|
this.activeDropdown = -1;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.activeDropdown = key;
|
this.activeDropdown = key;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,15 +303,33 @@ export default class BucketsView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public openBucket(): void {
|
/**
|
||||||
|
* Holds on bucket click. Proceeds to file browser.
|
||||||
|
*/
|
||||||
|
public openBucket(bucketName: string): void {
|
||||||
|
this.$store.dispatch(OBJECTS_ACTIONS.SET_FILE_COMPONENT_BUCKET_NAME, bucketName);
|
||||||
this.$router.push(RouteConfig.Objects.with(RouteConfig.UploadFile).path);
|
this.$router.push(RouteConfig.Objects.with(RouteConfig.UploadFile).path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns fetched buckets from store.
|
* Returns fetched buckets from store.
|
||||||
*/
|
*/
|
||||||
public get buckets(): Bucket[] {
|
public get bucketsList(): Bucket[] {
|
||||||
return this.$store.state.objectsModule.buckets;
|
return this.$store.state.objectsModule.bucketsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns passphrase from store.
|
||||||
|
*/
|
||||||
|
private get passphrase(): string {
|
||||||
|
return this.$store.state.objectsModule.passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns access grant from store.
|
||||||
|
*/
|
||||||
|
private get accessGrantFromStore(): string {
|
||||||
|
return this.$store.state.objectsModule.accessGrant;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -22,7 +22,7 @@ import GeneratePassphrase from '@/components/common/GeneratePassphrase.vue';
|
|||||||
|
|
||||||
import { RouteConfig } from '@/router';
|
import { RouteConfig } from '@/router';
|
||||||
import { OBJECTS_ACTIONS } from '@/store/modules/objects';
|
import { OBJECTS_ACTIONS } from '@/store/modules/objects';
|
||||||
import { LocalData } from '@/utils/localData';
|
import { LocalData, UserIDPassSalt } from '@/utils/localData';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
@ -31,9 +31,21 @@ import { LocalData } from '@/utils/localData';
|
|||||||
})
|
})
|
||||||
export default class CreatePassphrase extends Vue {
|
export default class CreatePassphrase extends Vue {
|
||||||
private isLoading: boolean = false;
|
private isLoading: boolean = false;
|
||||||
|
private keyToBeStored: string = '';
|
||||||
|
|
||||||
public passphrase: string = '';
|
public passphrase: string = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook after initial render.
|
||||||
|
* Chooses correct route.
|
||||||
|
*/
|
||||||
|
public mounted(): void {
|
||||||
|
const idPassSalt: UserIDPassSalt | null = LocalData.getUserIDPassSalt();
|
||||||
|
if (idPassSalt && idPassSalt.userId === this.$store.getters.user.id) {
|
||||||
|
this.$router.push({name: RouteConfig.EnterPassphrase.name});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets passphrase from child component.
|
* Sets passphrase from child component.
|
||||||
*/
|
*/
|
||||||
@ -44,22 +56,43 @@ export default class CreatePassphrase extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Holds on next button click logic.
|
* Holds on next button click logic.
|
||||||
*/
|
*/
|
||||||
public onNextClick(): void {
|
public async onNextClick(): Promise<void> {
|
||||||
if (this.isLoading) return;
|
if (this.isLoading) return;
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
const SALT = 'storj-unique-salt';
|
const SALT = 'storj-unique-salt';
|
||||||
pbkdf2.pbkdf2(this.passphrase, SALT, 1, 64, (error, key) => {
|
|
||||||
if (error) return this.$notify.error(error.message);
|
|
||||||
|
|
||||||
LocalData.setUserIDPassSalt(this.$store.getters.user.id, key.toString('hex'), SALT);
|
const result: Buffer | Error = await this.pbkdf2Async(SALT);
|
||||||
});
|
|
||||||
|
if (result instanceof Error) {
|
||||||
|
await this.$notify.error(result.message);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.keyToBeStored = await result.toString('hex');
|
||||||
|
|
||||||
|
await LocalData.setUserIDPassSalt(this.$store.getters.user.id, this.keyToBeStored, SALT);
|
||||||
|
await this.$store.dispatch(OBJECTS_ACTIONS.SET_PASSPHRASE, this.passphrase);
|
||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
|
||||||
this.$store.dispatch(OBJECTS_ACTIONS.SET_PASSPHRASE, this.passphrase);
|
await this.$router.push({name: RouteConfig.EnterPassphrase.name});
|
||||||
this.$router.push({name: RouteConfig.BucketsManagement.name});
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates passphrase fingerprint asynchronously.
|
||||||
|
*/
|
||||||
|
private pbkdf2Async(salt: string): Promise<Buffer | Error> {
|
||||||
|
const ITERATIONS = 1;
|
||||||
|
const KEY_LENGTH = 64;
|
||||||
|
|
||||||
|
return new Promise((response, reject) => {
|
||||||
|
pbkdf2.pbkdf2(this.passphrase, salt, ITERATIONS, KEY_LENGTH, (error, key) => {
|
||||||
|
error ? reject(error) : response(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -13,14 +13,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="enter-pass__container__warning__message">
|
<p class="enter-pass__container__warning__message">
|
||||||
Entering your encryption passphrase here will share encryption data with your browser.
|
Entering your encryption passphrase here will share encryption data with your browser.
|
||||||
<a
|
|
||||||
class="enter-pass__container__warning__message__link"
|
|
||||||
:href="docsLink"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener norefferer"
|
|
||||||
>
|
|
||||||
Learn More
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="enter-pass__container__textarea" for="enter-pass-textarea">
|
<label class="enter-pass__container__textarea" for="enter-pass-textarea">
|
||||||
@ -38,8 +30,9 @@
|
|||||||
<div class="enter-pass__container__error" v-if="isError">
|
<div class="enter-pass__container__error" v-if="isError">
|
||||||
<h2 class="enter-pass__container__error__title">Encryption Passphrase Does not Match</h2>
|
<h2 class="enter-pass__container__error__title">Encryption Passphrase Does not Match</h2>
|
||||||
<p class="enter-pass__container__error__message">
|
<p class="enter-pass__container__error__message">
|
||||||
This passphrase hasn’t yet been used in the browser. Please ensure this is the encryption passphrase
|
A previous fingerprint of a passphrase-based-key-derivation-function created in this browser doesn't
|
||||||
used in libulink or the Uplink CLI.
|
match the passphrase you just entered. Entering a passphrase not previously created will result in
|
||||||
|
the creation of a new passphrase.
|
||||||
</p>
|
</p>
|
||||||
<label class="enter-pass__container__error__check-area" :class="{ error: isCheckboxError }" for="error-checkbox">
|
<label class="enter-pass__container__error__check-area" :class="{ error: isCheckboxError }" for="error-checkbox">
|
||||||
<input
|
<input
|
||||||
@ -84,11 +77,24 @@ import { MetaUtils } from '@/utils/meta';
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class EnterPassphrase extends Vue {
|
export default class EnterPassphrase extends Vue {
|
||||||
|
private hashFromInput: string = '';
|
||||||
|
|
||||||
public passphrase: string = '';
|
public passphrase: string = '';
|
||||||
public isError: boolean = false;
|
public isError: boolean = false;
|
||||||
public isCheckboxChecked: boolean = false;
|
public isCheckboxChecked: boolean = false;
|
||||||
public isCheckboxError: boolean = false;
|
public isCheckboxError: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook after initial render.
|
||||||
|
* Chooses correct route.
|
||||||
|
*/
|
||||||
|
public mounted(): void {
|
||||||
|
const idPassSalt: UserIDPassSalt | null = LocalData.getUserIDPassSalt();
|
||||||
|
if (!idPassSalt) {
|
||||||
|
this.$router.push({name: RouteConfig.CreatePassphrase.name});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns docs link from config.
|
* Returns docs link from config.
|
||||||
*/
|
*/
|
||||||
@ -99,38 +105,43 @@ export default class EnterPassphrase extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Holds on access data button click logic.
|
* Holds on access data button click logic.
|
||||||
*/
|
*/
|
||||||
public onAccessDataClick(): void {
|
public async onAccessDataClick(): Promise<void> {
|
||||||
if (!this.passphrase) return;
|
if (!this.passphrase) return;
|
||||||
|
|
||||||
const hashFromStorage: UserIDPassSalt | null = LocalData.getUserIDPassSalt();
|
const hashFromStorage: UserIDPassSalt | null = LocalData.getUserIDPassSalt();
|
||||||
if (!hashFromStorage) return;
|
if (!hashFromStorage) return;
|
||||||
|
|
||||||
pbkdf2.pbkdf2(this.passphrase, hashFromStorage.salt, 1, 64, (error, key) => {
|
const result: Buffer | Error = await this.pbkdf2Async(hashFromStorage.salt);
|
||||||
if (error) return this.$notify.error(error.message);
|
|
||||||
|
|
||||||
const hashFromInput: string = key.toString('hex');
|
if (result instanceof Error) {
|
||||||
const areHashesEqual = () => {
|
await this.$notify.error(result.message);
|
||||||
return hashFromStorage.passwordHash === hashFromInput;
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (true) {
|
return;
|
||||||
case areHashesEqual() ||
|
}
|
||||||
!areHashesEqual() && this.isError && this.isCheckboxChecked:
|
|
||||||
this.$store.dispatch(OBJECTS_ACTIONS.SET_PASSPHRASE, this.passphrase);
|
|
||||||
this.$router.push({name: RouteConfig.BucketsManagement.name});
|
|
||||||
|
|
||||||
return;
|
this.hashFromInput = await result.toString('hex');
|
||||||
case !areHashesEqual() && this.isError && !this.isCheckboxChecked:
|
|
||||||
this.isCheckboxError = true;
|
|
||||||
|
|
||||||
return;
|
const areHashesEqual = () => {
|
||||||
case !areHashesEqual():
|
return hashFromStorage.passwordHash === this.hashFromInput;
|
||||||
this.isError = true;
|
};
|
||||||
|
|
||||||
return;
|
switch (true) {
|
||||||
default:
|
case areHashesEqual() ||
|
||||||
}
|
!areHashesEqual() && this.isError && this.isCheckboxChecked:
|
||||||
});
|
await this.$store.dispatch(OBJECTS_ACTIONS.SET_PASSPHRASE, this.passphrase);
|
||||||
|
await this.$router.push({name: RouteConfig.BucketsManagement.name});
|
||||||
|
|
||||||
|
return;
|
||||||
|
case !areHashesEqual() && this.isError && !this.isCheckboxChecked:
|
||||||
|
this.isCheckboxError = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
case !areHashesEqual():
|
||||||
|
this.isError = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -141,6 +152,20 @@ export default class EnterPassphrase extends Vue {
|
|||||||
this.isCheckboxChecked = false;
|
this.isCheckboxChecked = false;
|
||||||
this.isError = false;
|
this.isError = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates passphrase fingerprint asynchronously.
|
||||||
|
*/
|
||||||
|
private pbkdf2Async(salt: string): Promise<Buffer | Error> {
|
||||||
|
const ITERATIONS = 1;
|
||||||
|
const KEY_LENGTH = 64;
|
||||||
|
|
||||||
|
return new Promise((response, reject) => {
|
||||||
|
pbkdf2.pbkdf2(this.passphrase, salt, ITERATIONS, KEY_LENGTH, (error, key) => {
|
||||||
|
error ? reject(error) : response(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -212,12 +237,6 @@ export default class EnterPassphrase extends Vue {
|
|||||||
line-height: 19px;
|
line-height: 19px;
|
||||||
color: #1b2533;
|
color: #1b2533;
|
||||||
margin: 10px 0 0 0;
|
margin: 10px 0 0 0;
|
||||||
|
|
||||||
&__link {
|
|
||||||
font-family: 'font_medium', sans-serif;
|
|
||||||
color: #0068dc;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,39 +11,17 @@
|
|||||||
import { Component, Vue } from 'vue-property-decorator';
|
import { Component, Vue } from 'vue-property-decorator';
|
||||||
|
|
||||||
import { RouteConfig } from '@/router';
|
import { RouteConfig } from '@/router';
|
||||||
import { LocalData, UserIDPassSalt } from '@/utils/localData';
|
import { MetaUtils } from '@/utils/meta';
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export default class ObjectsArea extends Vue {
|
export default class ObjectsArea extends Vue {
|
||||||
/**
|
/**
|
||||||
* Lifecycle hook after initial render.
|
* Lifecycle hook after initial render.
|
||||||
* Chooses correct route.
|
* Redirects if flow is disabled.
|
||||||
*/
|
*/
|
||||||
public async mounted(): Promise<void> {
|
public async mounted(): Promise<void> {
|
||||||
const DUPLICATE_NAV_ERROR: string = 'NavigationDuplicated';
|
if (await JSON.parse(MetaUtils.getMetaContent('file-browser-flow-disabled'))) {
|
||||||
const idPassSalt: UserIDPassSalt | null = LocalData.getUserIDPassSalt();
|
await this.$router.push(RouteConfig.ProjectDashboard.path);
|
||||||
if (idPassSalt && idPassSalt.userId === this.$store.getters.user.id) {
|
|
||||||
try {
|
|
||||||
await this.$router.push(RouteConfig.Objects.with(RouteConfig.EnterPassphrase).path);
|
|
||||||
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === DUPLICATE_NAV_ERROR) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.$notify.error(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.$router.push(RouteConfig.Objects.with(RouteConfig.CreatePassphrase).path);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === DUPLICATE_NAV_ERROR) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.$notify.error(error.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,199 @@
|
|||||||
// Copyright (C) 2021 Storj Labs, Inc.
|
// Copyright (C) 2021 Storj Labs, Inc.
|
||||||
// See LICENSE for copying information.
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="file-browser">
|
||||||
|
<FileBrowser></FileBrowser>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { FileBrowser } from 'browser';
|
||||||
import { Component, Vue } from 'vue-property-decorator';
|
import { Component, Vue } from 'vue-property-decorator';
|
||||||
|
|
||||||
@Component
|
import { RouteConfig } from '@/router';
|
||||||
export default class UploadFile extends Vue {}
|
import { ACCESS_GRANTS_ACTIONS } from '@/store/modules/accessGrants';
|
||||||
|
import { AccessGrant, GatewayCredentials } from '@/types/accessGrants';
|
||||||
|
import { MetaUtils } from '@/utils/meta';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
FileBrowser,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class UploadFile extends Vue {
|
||||||
|
private worker: Worker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook before initial render.
|
||||||
|
* Checks if bucket is chosen.
|
||||||
|
*/
|
||||||
|
public beforeMount(): void {
|
||||||
|
if (!this.bucket) {
|
||||||
|
this.$router.push(RouteConfig.Objects.with(RouteConfig.EnterPassphrase).path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook after initial render.
|
||||||
|
* Checks if bucket is chosen.
|
||||||
|
* Sets local worker.
|
||||||
|
*/
|
||||||
|
public mounted(): void {
|
||||||
|
if (!this.bucket) {
|
||||||
|
this.$router.push(RouteConfig.Objects.with(RouteConfig.EnterPassphrase).path);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hook after vue instance was created.
|
||||||
|
* Initiates file browser.
|
||||||
|
*/
|
||||||
|
public created(): void {
|
||||||
|
if (!this.bucket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.commit('files/init', {
|
||||||
|
endpoint: this.$store.state.objectsModule.gatewayCredentials.endpoint,
|
||||||
|
accessKey: this.$store.state.objectsModule.gatewayCredentials.accessKeyId,
|
||||||
|
secretKey: this.$store.state.objectsModule.gatewayCredentials.secretKey,
|
||||||
|
bucket: this.bucket,
|
||||||
|
browserRoot: RouteConfig.Objects.with(RouteConfig.UploadFile).path,
|
||||||
|
getObjectMapUrl: async (path: string) => await this.generateObjectMapUrl(path),
|
||||||
|
getSharedLink: async (path: string) => await this.generateShareLinkUrl(path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a URL for an object map.
|
||||||
|
*/
|
||||||
|
public async generateObjectMapUrl(path: string): Promise<string> {
|
||||||
|
path = `${this.bucket}/${path}`;
|
||||||
|
const now = new Date();
|
||||||
|
const inADay = new Date(now.setDate(now.getDate() + 1));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key: string = await this.accessKey(this.apiKey, inADay, path);
|
||||||
|
|
||||||
|
return `https://link.tardigradeshare.io/s/${key}/${path}?map=1`;
|
||||||
|
} catch (error) {
|
||||||
|
await this.$notify.error(error.message);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a URL for a link sharing service.
|
||||||
|
*/
|
||||||
|
public async generateShareLinkUrl(path: string): Promise<string> {
|
||||||
|
path = `${this.bucket}/${path}`;
|
||||||
|
const now = new Date();
|
||||||
|
const notAfter = new Date(now.setFullYear(now.getFullYear() + 100));
|
||||||
|
const LINK_SHARING_AG_NAME = `${path}_shared-object_${now.toISOString()}`;
|
||||||
|
const cleanAPIKey: AccessGrant = await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.CREATE, LINK_SHARING_AG_NAME);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key: string = await this.accessKey(cleanAPIKey.secret, notAfter, path);
|
||||||
|
|
||||||
|
return `https://link.tardigradeshare.io/${key}/${path}`;
|
||||||
|
} catch (error) {
|
||||||
|
await this.$notify.error(error.message);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets local worker with worker instantiated in store.
|
||||||
|
*/
|
||||||
|
public setWorker(): void {
|
||||||
|
this.worker = this.$store.state.accessGrantsModule.accessGrantsWebWorker;
|
||||||
|
this.worker.onerror = (error: ErrorEvent) => {
|
||||||
|
this.$notify.error(error.message);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates public access key.
|
||||||
|
*/
|
||||||
|
private async accessKey(cleanApiKey: string, notAfter: Date, path: string): Promise<string> {
|
||||||
|
const satelliteNodeURL = MetaUtils.getMetaContent('satellite-nodeurl');
|
||||||
|
|
||||||
|
this.worker.postMessage({
|
||||||
|
'type': 'GenerateAccess',
|
||||||
|
'apiKey': cleanApiKey,
|
||||||
|
'passphrase': this.passphrase,
|
||||||
|
'projectID': this.$store.getters.selectedProject.id,
|
||||||
|
'satelliteNodeURL': satelliteNodeURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const grantEvent: MessageEvent = await new Promise(resolve => this.worker.onmessage = resolve);
|
||||||
|
const grantData = grantEvent.data;
|
||||||
|
if (grantData.error) {
|
||||||
|
await this.$notify.error(grantData.error);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.worker.postMessage({
|
||||||
|
'type': 'RestrictGrant',
|
||||||
|
'isDownload': true,
|
||||||
|
'isUpload': true,
|
||||||
|
'isList': true,
|
||||||
|
'isDelete': true,
|
||||||
|
'paths': [path],
|
||||||
|
'grant': grantData.value,
|
||||||
|
'notBefore': new Date().toISOString(),
|
||||||
|
'notAfter': notAfter.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const event: MessageEvent = await new Promise(resolve => this.worker.onmessage = resolve);
|
||||||
|
const data = event.data;
|
||||||
|
if (data.error) {
|
||||||
|
await this.$notify.error(data.error);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const gatewayCredentials: GatewayCredentials = await this.$store.dispatch(ACCESS_GRANTS_ACTIONS.GET_GATEWAY_CREDENTIALS, {accessGrant: data.value, isPublic: true});
|
||||||
|
|
||||||
|
return gatewayCredentials.accessKeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns passphrase from store.
|
||||||
|
*/
|
||||||
|
private get passphrase(): string {
|
||||||
|
return this.$store.state.objectsModule.passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns apiKey from store.
|
||||||
|
*/
|
||||||
|
private get apiKey(): string {
|
||||||
|
return this.$store.state.objectsModule.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns bucket name from store.
|
||||||
|
*/
|
||||||
|
private get bucket(): string {
|
||||||
|
return this.$store.state.objectsModule.fileComponentBucketName;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import '../../../node_modules/browser/dist/browser.css';
|
||||||
|
|
||||||
|
.file-browser {
|
||||||
|
font-family: 'font_regular', sans-serif;
|
||||||
|
padding-bottom: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -388,6 +388,12 @@ router.beforeEach((to, from, next) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (navigateToDefaultSubTab(to.matched, RouteConfig.Objects)) {
|
||||||
|
next(RouteConfig.Objects.with(RouteConfig.CreatePassphrase).path);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (to.name === 'default') {
|
if (to.name === 'default') {
|
||||||
next(RouteConfig.ProjectDashboard.path);
|
next(RouteConfig.ProjectDashboard.path);
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
// Copyright (C) 2019 Storj Labs, Inc.
|
// Copyright (C) 2019 Storj Labs, Inc.
|
||||||
// See LICENSE for copying information.
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
import { files } from 'browser';
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import Vuex from 'vuex';
|
import Vuex from 'vuex';
|
||||||
|
|
||||||
@ -62,6 +63,7 @@ export const store = new Vuex.Store<ModulesState>({
|
|||||||
projectsModule: makeProjectsModule(projectsApi),
|
projectsModule: makeProjectsModule(projectsApi),
|
||||||
bucketUsageModule: makeBucketsModule(bucketsApi),
|
bucketUsageModule: makeBucketsModule(bucketsApi),
|
||||||
objectsModule: makeObjectsModule(),
|
objectsModule: makeObjectsModule(),
|
||||||
|
files,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -215,7 +215,7 @@ export function makeAccessGrantsModule(api: AccessGrantsApi): StoreModule<Access
|
|||||||
await api.deleteByNameAndProjectID(name, rootGetters.selectedProject.id);
|
await api.deleteByNameAndProjectID(name, rootGetters.selectedProject.id);
|
||||||
},
|
},
|
||||||
getGatewayCredentials: async function({commit}: any, payload): Promise<GatewayCredentials> {
|
getGatewayCredentials: async function({commit}: any, payload): Promise<GatewayCredentials> {
|
||||||
const credentials: GatewayCredentials = await api.getGatewayCredentials(payload.accessGrant, payload.optionalURL);
|
const credentials: GatewayCredentials = await api.getGatewayCredentials(payload.accessGrant, payload.optionalURL, payload.isPublic);
|
||||||
|
|
||||||
commit(SET_GATEWAY_CREDENTIALS, credentials);
|
commit(SET_GATEWAY_CREDENTIALS, credentials);
|
||||||
|
|
||||||
|
@ -1,23 +1,19 @@
|
|||||||
// Copyright (C) 2021 Storj Labs, Inc.
|
// Copyright (C) 2021 Storj Labs, Inc.
|
||||||
// See LICENSE for copying information.
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
import S3, { Bucket } from 'aws-sdk/clients/s3';
|
||||||
|
|
||||||
import { StoreModule } from '@/store';
|
import { StoreModule } from '@/store';
|
||||||
import { GatewayCredentials } from '@/types/accessGrants';
|
import { GatewayCredentials } from '@/types/accessGrants';
|
||||||
import {
|
|
||||||
Bucket,
|
|
||||||
CreateBucketCommand,
|
|
||||||
DeleteBucketCommand,
|
|
||||||
ListBucketsCommand,
|
|
||||||
ListBucketsOutput,
|
|
||||||
S3Client,
|
|
||||||
} from '@aws-sdk/client-s3';
|
|
||||||
|
|
||||||
export const OBJECTS_ACTIONS = {
|
export const OBJECTS_ACTIONS = {
|
||||||
CLEAR: 'clearObjects',
|
CLEAR: 'clearObjects',
|
||||||
SET_GATEWAY_CREDENTIALS: 'setGatewayCredentials',
|
SET_GATEWAY_CREDENTIALS: 'setGatewayCredentials',
|
||||||
|
SET_API_KEY: 'setApiKey',
|
||||||
SET_ACCESS_GRANT: 'setAccessGrant',
|
SET_ACCESS_GRANT: 'setAccessGrant',
|
||||||
SET_S3_CLIENT: 'setS3Client',
|
SET_S3_CLIENT: 'setS3Client',
|
||||||
SET_PASSPHRASE: 'setPassphrase',
|
SET_PASSPHRASE: 'setPassphrase',
|
||||||
|
SET_FILE_COMPONENT_BUCKET_NAME: 'setFileComponentBucketName',
|
||||||
FETCH_BUCKETS: 'fetchBuckets',
|
FETCH_BUCKETS: 'fetchBuckets',
|
||||||
CREATE_BUCKET: 'createBucket',
|
CREATE_BUCKET: 'createBucket',
|
||||||
DELETE_BUCKET: 'deleteBucket',
|
DELETE_BUCKET: 'deleteBucket',
|
||||||
@ -25,28 +21,34 @@ export const OBJECTS_ACTIONS = {
|
|||||||
|
|
||||||
export const OBJECTS_MUTATIONS = {
|
export const OBJECTS_MUTATIONS = {
|
||||||
SET_GATEWAY_CREDENTIALS: 'setGatewayCredentials',
|
SET_GATEWAY_CREDENTIALS: 'setGatewayCredentials',
|
||||||
|
SET_API_KEY: 'setApiKey',
|
||||||
SET_ACCESS_GRANT: 'setAccessGrant',
|
SET_ACCESS_GRANT: 'setAccessGrant',
|
||||||
CLEAR: 'clearObjects',
|
CLEAR: 'clearObjects',
|
||||||
SET_S3_CLIENT: 'setS3Client',
|
SET_S3_CLIENT: 'setS3Client',
|
||||||
SET_BUCKETS: 'setBuckets',
|
SET_BUCKETS: 'setBuckets',
|
||||||
|
SET_FILE_COMPONENT_BUCKET_NAME: 'setFileComponentBucketName',
|
||||||
SET_PASSPHRASE: 'setPassphrase',
|
SET_PASSPHRASE: 'setPassphrase',
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
CLEAR,
|
CLEAR,
|
||||||
|
SET_API_KEY,
|
||||||
SET_ACCESS_GRANT,
|
SET_ACCESS_GRANT,
|
||||||
SET_GATEWAY_CREDENTIALS,
|
SET_GATEWAY_CREDENTIALS,
|
||||||
SET_S3_CLIENT,
|
SET_S3_CLIENT,
|
||||||
SET_BUCKETS,
|
SET_BUCKETS,
|
||||||
SET_PASSPHRASE,
|
SET_PASSPHRASE,
|
||||||
|
SET_FILE_COMPONENT_BUCKET_NAME,
|
||||||
} = OBJECTS_MUTATIONS;
|
} = OBJECTS_MUTATIONS;
|
||||||
|
|
||||||
export class ObjectsState {
|
export class ObjectsState {
|
||||||
|
public apiKey: string = '';
|
||||||
public accessGrant: string = '';
|
public accessGrant: string = '';
|
||||||
public gatewayCredentials: GatewayCredentials = new GatewayCredentials();
|
public gatewayCredentials: GatewayCredentials = new GatewayCredentials();
|
||||||
public s3Client: S3Client = new S3Client({});
|
public s3Client: S3 = new S3({});
|
||||||
public buckets: Bucket[] = [];
|
public bucketsList: Bucket[] = [];
|
||||||
public passphrase: string = '';
|
public passphrase: string = '';
|
||||||
|
public fileComponentBucketName: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -56,6 +58,9 @@ export function makeObjectsModule(): StoreModule<ObjectsState> {
|
|||||||
return {
|
return {
|
||||||
state: new ObjectsState(),
|
state: new ObjectsState(),
|
||||||
mutations: {
|
mutations: {
|
||||||
|
[SET_API_KEY](state: ObjectsState, apiKey: string) {
|
||||||
|
state.apiKey = apiKey;
|
||||||
|
},
|
||||||
[SET_ACCESS_GRANT](state: ObjectsState, accessGrant: string) {
|
[SET_ACCESS_GRANT](state: ObjectsState, accessGrant: string) {
|
||||||
state.accessGrant = accessGrant;
|
state.accessGrant = accessGrant;
|
||||||
},
|
},
|
||||||
@ -63,36 +68,37 @@ export function makeObjectsModule(): StoreModule<ObjectsState> {
|
|||||||
state.gatewayCredentials = credentials;
|
state.gatewayCredentials = credentials;
|
||||||
},
|
},
|
||||||
[SET_S3_CLIENT](state: ObjectsState) {
|
[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 = {
|
const s3Config = {
|
||||||
credentials: {
|
accessKeyId: state.gatewayCredentials.accessKeyId,
|
||||||
accessKeyId: state.gatewayCredentials.accessKeyId,
|
secretAccessKey: state.gatewayCredentials.secretKey,
|
||||||
secretAccessKey: state.gatewayCredentials.secretKey,
|
|
||||||
},
|
|
||||||
endpoint: state.gatewayCredentials.endpoint,
|
endpoint: state.gatewayCredentials.endpoint,
|
||||||
s3ForcePathStyle: true,
|
|
||||||
signatureVersion: 'v4',
|
|
||||||
region: 'REGION',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
state.s3Client = new S3Client(s3Config);
|
state.s3Client = new S3(s3Config);
|
||||||
},
|
},
|
||||||
[SET_BUCKETS](state: ObjectsState, buckets: Bucket[]) {
|
[SET_BUCKETS](state: ObjectsState, buckets: Bucket[]) {
|
||||||
state.buckets = buckets;
|
state.bucketsList = buckets;
|
||||||
},
|
},
|
||||||
[SET_PASSPHRASE](state: ObjectsState, passphrase: string) {
|
[SET_PASSPHRASE](state: ObjectsState, passphrase: string) {
|
||||||
state.passphrase = passphrase;
|
state.passphrase = passphrase;
|
||||||
},
|
},
|
||||||
|
[SET_FILE_COMPONENT_BUCKET_NAME](state: ObjectsState, bucketName: string) {
|
||||||
|
state.fileComponentBucketName = bucketName;
|
||||||
|
},
|
||||||
[CLEAR](state: ObjectsState) {
|
[CLEAR](state: ObjectsState) {
|
||||||
|
state.apiKey = '';
|
||||||
|
state.passphrase = '';
|
||||||
state.accessGrant = '';
|
state.accessGrant = '';
|
||||||
state.gatewayCredentials = new GatewayCredentials();
|
state.gatewayCredentials = new GatewayCredentials();
|
||||||
|
state.s3Client = new S3({});
|
||||||
|
state.bucketsList = [];
|
||||||
|
state.fileComponentBucketName = '';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
setApiKey: function({commit}: any, apiKey: string): void {
|
||||||
|
commit(SET_API_KEY, apiKey);
|
||||||
|
},
|
||||||
setAccessGrant: function({commit}: any, accessGrant: string): void {
|
setAccessGrant: function({commit}: any, accessGrant: string): void {
|
||||||
commit(SET_ACCESS_GRANT, accessGrant);
|
commit(SET_ACCESS_GRANT, accessGrant);
|
||||||
},
|
},
|
||||||
@ -105,25 +111,23 @@ export function makeObjectsModule(): StoreModule<ObjectsState> {
|
|||||||
setPassphrase: function({commit}: any, passphrase: string): void {
|
setPassphrase: function({commit}: any, passphrase: string): void {
|
||||||
commit(SET_PASSPHRASE, passphrase);
|
commit(SET_PASSPHRASE, passphrase);
|
||||||
},
|
},
|
||||||
|
setFileComponentBucketName: function({commit}: any, bucketName: string): void {
|
||||||
|
commit(SET_FILE_COMPONENT_BUCKET_NAME, bucketName);
|
||||||
|
},
|
||||||
fetchBuckets: async function(ctx): Promise<void> {
|
fetchBuckets: async function(ctx): Promise<void> {
|
||||||
const bucketsOutput: ListBucketsOutput = await ctx.state.s3Client.send(new ListBucketsCommand({
|
const result = await ctx.state.s3Client.listBuckets().promise();
|
||||||
credentials: {
|
|
||||||
accessKeyId: ctx.state.gatewayCredentials.accessKeyId,
|
|
||||||
secretAccessKey: ctx.state.gatewayCredentials.secretKey,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
ctx.commit(SET_BUCKETS, bucketsOutput.Buckets);
|
ctx.commit(SET_BUCKETS, result.Buckets);
|
||||||
},
|
},
|
||||||
createBucket: async function(ctx, name: string): Promise<void> {
|
createBucket: async function(ctx, name: string): Promise<void> {
|
||||||
await ctx.state.s3Client.send(new CreateBucketCommand({
|
await ctx.state.s3Client.createBucket({
|
||||||
Bucket: name,
|
Bucket: name,
|
||||||
}));
|
}).promise();
|
||||||
},
|
},
|
||||||
deleteBucket: async function(ctx, name: string): Promise<void> {
|
deleteBucket: async function(ctx, name: string): Promise<void> {
|
||||||
await ctx.state.s3Client.send(new DeleteBucketCommand({
|
await ctx.state.s3Client.deleteBucket({
|
||||||
Bucket: name,
|
Bucket: name,
|
||||||
}));
|
}).promise();
|
||||||
},
|
},
|
||||||
clearObjects: function ({commit}: any): void {
|
clearObjects: function ({commit}: any): void {
|
||||||
commit(CLEAR);
|
commit(CLEAR);
|
||||||
|
@ -47,7 +47,7 @@ export interface AccessGrantsApi {
|
|||||||
* @returns GatewayCredentials
|
* @returns GatewayCredentials
|
||||||
* @throws Error
|
* @throws Error
|
||||||
*/
|
*/
|
||||||
getGatewayCredentials(accessGrant: string, optionalURL?: string): Promise<GatewayCredentials>;
|
getGatewayCredentials(accessGrant: string, optionalURL?: string, isPublic?: boolean): Promise<GatewayCredentials>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -53,7 +53,7 @@ self.onmessage = function (event) {
|
|||||||
permission.NotBefore = notBefore;
|
permission.NotBefore = notBefore;
|
||||||
permission.NotAfter = notAfter;
|
permission.NotAfter = notAfter;
|
||||||
|
|
||||||
if (data.type == "SetPermission") {
|
if (data.type === "SetPermission") {
|
||||||
const buckets = data.buckets;
|
const buckets = data.buckets;
|
||||||
apiKey = data.apiKey;
|
apiKey = data.apiKey;
|
||||||
result = self.setAPIKeyPermission(apiKey, buckets, permission);
|
result = self.setAPIKeyPermission(apiKey, buckets, permission);
|
||||||
|
Loading…
Reference in New Issue
Block a user