From f4f9940fc81992b894ca1591213fc5701b5b8f49 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 3 Feb 2024 14:55:55 +0100 Subject: [PATCH 01/25] update status badge [ci skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c3e07400b..58efdc6dd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build and test](https://github.com/cryptomator/hub/actions/workflows/buildAndTest.yml/badge.svg)](https://github.com/cryptomator/hub/actions/workflows/buildAndTest.yml) +[![CI Build](https://github.com/cryptomator/hub/actions/workflows/build.yml/badge.svg)](https://github.com/cryptomator/hub/actions/workflows/build.yml) # Cryptomator Hub From 3896fffba1668dbca07a20894884715e0a8175eb Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Fri, 9 Feb 2024 22:10:04 +0100 Subject: [PATCH 02/25] WIP handle http status code 402 --- frontend/src/common/backend.ts | 2 +- frontend/src/components/VaultDetails.vue | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index 83fccea56..f231a8ee0 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -267,7 +267,7 @@ class VaultService { public async accessToken(vaultId: string, evenIfArchived = false): Promise { return axiosAuth.get(`/vaults/${vaultId}/access-token?evenIfArchived=${evenIfArchived}`, { headers: { 'Content-Type': 'text/plain' } }) .then(response => response.data) - .catch((error) => rethrowAndConvertIfExpected(error, 403)); + .catch((error) => rethrowAndConvertIfExpected(error, 402, 403)); } public async grantAccess(vaultId: string, ...grants: AccessGrant[]) { diff --git a/frontend/src/components/VaultDetails.vue b/frontend/src/components/VaultDetails.vue index 31e3a1854..4562850e7 100644 --- a/frontend/src/components/VaultDetails.vue +++ b/frontend/src/components/VaultDetails.vue @@ -260,14 +260,16 @@ async function fetchData() { async function fetchOwnerData() { try { - const vaultKeyJwe = await backend.vaults.accessToken(props.vaultId, true); - vaultKeys.value = await loadVaultKeys(vaultKeyJwe); (await backend.vaults.getMembers(props.vaultId)).forEach(member => members.value.set(member.id, member)); usersRequiringAccessGrant.value = await backend.vaults.getUsersRequiringAccessGrant(props.vaultId); vaultRecoveryRequired.value = false; + const vaultKeyJwe = await backend.vaults.accessToken(props.vaultId, true); + vaultKeys.value = await loadVaultKeys(vaultKeyJwe); } catch (error) { if (error instanceof ForbiddenError) { vaultRecoveryRequired.value = true; + } else if (error instanceof PaymentRequiredError) { + // TODO set paymentRequiredFlag and adjust UI accordingly } else { console.error('Retrieving ownership failed.', error); onFetchError.value = error instanceof Error ? error : new Error('Unknown Error'); From bec743260972de3d367924faf1d8998746107f3e Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 10 Feb 2024 10:07:48 +0100 Subject: [PATCH 03/25] simplify test --- .../hub/api/VaultResourceTest.java | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java index 4383143f3..14c8ea847 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java @@ -312,29 +312,11 @@ public void testUpdateVault() { public class GrantAccess { @Test - @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens returns 404 for [user998, user999, user666]") - public void testGrantAccess0() throws SQLException { - try (var c = dataSource.getConnection(); var s = c.createStatement()) { - s.execute(""" - INSERT INTO "authority" ("id", "type", "name") VALUES ('user998', 'USER', 'User 998'); - INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); - INSERT INTO "user_details" ("id") VALUES ('user998'); - INSERT INTO "user_details" ("id") VALUES ('user999'); - INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user998'); - INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user999'); - """); - } - - given().contentType(ContentType.JSON).body(Map.of("user998", "jwe.jwe.jwe.vault1.user998", "user999", "jwe.jwe.jwe.vault1.user999", "user666", "jwe.jwe.jwe.vault1.user666")) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens returns 404 for [user1, user666]") + public void testGrantAccess0() { + given().contentType(ContentType.JSON).body(Map.of("user1", "jwe.jwe.jwe.vault1.user1", "user666", "jwe.jwe.jwe.vault1.user666")) .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(404); - - try (var c = dataSource.getConnection(); var s = c.createStatement()) { - s.execute(""" - DELETE FROM "authority" WHERE "id" = 'user998'; - DELETE FROM "authority" WHERE "id" = 'user999'; - """); - } } @Test From 1a07d3e5ac97c41414144778fa2ac0b0593b2f83 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 10 Feb 2024 10:16:59 +0100 Subject: [PATCH 04/25] error 402 in `POST /api/vaults/{v}/access-tokens` --- .../org/cryptomator/hub/api/VaultResource.java | 13 ++++++++++++- .../hub/entities/EffectiveVaultAccess.java | 11 +++++++++++ .../cryptomator/hub/api/VaultResourceTest.java | 17 +++++++++++++++++ frontend/src/common/backend.ts | 2 +- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java index 6076817e6..3c80c8d49 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -59,7 +59,6 @@ import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.hibernate.exception.ConstraintViolationException; import java.net.URI; import java.time.Instant; @@ -67,7 +66,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; @Path("/vaults") @@ -335,6 +337,7 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr @Consumes(MediaType.APPLICATION_JSON) @Operation(summary = "adds user-specific vault keys", description = "Stores one or more user-vaultkey-tuples, as defined in the request body ({user1: token1, user2: token2, ...}).") @APIResponse(responseCode = "200", description = "all keys stored") + @APIResponse(responseCode = "402", description = "number of users granted access exceeds available license seats") @APIResponse(responseCode = "403", description = "not a vault owner") @APIResponse(responseCode = "404", description = "at least one user has not been found") @APIResponse(responseCode = "410", description = "vault is archived") @@ -344,6 +347,14 @@ public Response grantAccess(@PathParam("vaultId") UUID vaultId, @NotEmpty Map sittingUsers = EffectiveVaultAccess.getSeatOccupyingUserIds().collect(Collectors.toUnmodifiableSet()); + long occupiedSeats = sittingUsers.size(); + long usersWithoutSeat = tokens.keySet().stream().filter(Predicate.not(sittingUsers::contains)).count(); + if (occupiedSeats + usersWithoutSeat > license.getAvailableSeats()) { + throw new PaymentRequiredException("Number of effective vault users greater than or equal to the available license seats"); + } + for (var entry : tokens.entrySet()) { var userId = entry.getKey(); var token = AccessToken.findById(new AccessToken.AccessId(userId, vaultId)); diff --git a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java index a9ad708cf..6aa401728 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java @@ -17,6 +17,7 @@ import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; +import java.util.stream.Stream; @Entity @Immutable @@ -27,6 +28,12 @@ SELECT count(eva) INNER JOIN Vault v ON eva.id.vaultId = v.id AND NOT v.archived WHERE eva.id.authorityId = :userId """) +@NamedQuery(name = "EffectiveVaultAccess.getSeatOccupyingUserIds", query = """ + SELECT DISTINCT u.id + FROM User u + INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId + INNER JOIN Vault v ON eva.id.vaultId = v.id AND NOT v.archived + """) @NamedQuery(name = "EffectiveVaultAccess.countSeatOccupyingUsers", query = """ SELECT count(DISTINCT u) FROM User u @@ -55,6 +62,10 @@ public static boolean isUserOccupyingSeat(String userId) { return EffectiveVaultAccess.count("#EffectiveVaultAccess.countSeatsOccupiedByUser", Parameters.with("userId", userId)) > 0; } + public static Stream getSeatOccupyingUserIds() { + return getEntityManager().createNamedQuery("EffectiveVaultAccess.getSeatOccupyingUserIds", String.class).getResultStream(); + } + public static long countSeatOccupyingUsers() { return EffectiveVaultAccess.count("#EffectiveVaultAccess.countSeatOccupyingUsers"); } diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java index 14c8ea847..cd84b6c73 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java @@ -692,6 +692,23 @@ public void setup() throws SQLException { } } + @Test + @Order(0) + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens returns 402 for [user91, user92, user93, user94]") + public void grantAccessExceedingSeats() { + //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() == 2); + var body = Map.of( + "user91", "jwe.jwe.jwe.vault1.user91", // + "user92", "jwe.jwe.jwe.vault1.user92", // + "user93", "jwe.jwe.jwe.vault1.user93", // + "user94", "jwe.jwe.jwe.vault1.user94" // + ); + + given().contentType(ContentType.JSON).body(body) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(402); + } + @Test @Order(1) @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/groups/group91 returns 402") diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index f231a8ee0..bf03fe198 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -276,7 +276,7 @@ class VaultService { return accumulator; }, {}); await axiosAuth.post(`/vaults/${vaultId}/access-tokens`, body) - .catch((error) => rethrowAndConvertIfExpected(error, 404, 409)); + .catch((error) => rethrowAndConvertIfExpected(error, 402, 403, 404, 409)); } public async removeAuthority(vaultId: string, authorityId: string) { From 361968ecdc30b57f072eee89709856e42e4af023 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 10 Feb 2024 13:00:26 +0100 Subject: [PATCH 05/25] add a bunch of test users and groups --- backend/src/main/resources/dev-realm.json | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/backend/src/main/resources/dev-realm.json b/backend/src/main/resources/dev-realm.json index d54f0b445..d60eda306 100644 --- a/backend/src/main/resources/dev-realm.json +++ b/backend/src/main/resources/dev-realm.json @@ -64,6 +64,39 @@ "admin" ] }, + { + "username": "alice", + "enabled": true, + "credentials": [{"type": "password", "value": "asd"}], + "realmRoles": ["user"] + }, + { + "username": "bob", + "enabled": true, + "credentials": [{"type": "password", "value": "asd"}], + "realmRoles": ["user"] + }, + { + "username": "carol", + "enabled": true, + "credentials": [{"type": "password", "value": "asd"}], + "realmRoles": ["user"], + "groups" : [ "/groupies" ] + }, + { + "username": "dave", + "enabled": true, + "credentials": [{"type": "password", "value": "asd"}], + "realmRoles": ["user"], + "groups" : [ "/groupies" ] + }, + { + "username": "erin", + "enabled": true, + "credentials": [{"type": "password", "value": "asd"}], + "realmRoles": ["user"], + "groups" : [ "/groupies" ] + }, { "username": "syncer", "email": "syncer@localhost", @@ -94,6 +127,16 @@ } } ], + "groups": [ + { + "name": "groupies", + "path": "/groupies", + "subGroups": [], + "attributes": {}, + "realmRoles": [], + "clientRoles": {} + } + ], "scopeMappings": [ { "client": "cryptomatorhub", From b0da9ae4e5de36837b093408ab49f26e13909e63 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 10 Feb 2024 13:00:56 +0100 Subject: [PATCH 06/25] disable parts of vault detail UI on error 402 --- frontend/src/components/VaultDetails.vue | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/VaultDetails.vue b/frontend/src/components/VaultDetails.vue index 4562850e7..f59799d40 100644 --- a/frontend/src/components/VaultDetails.vue +++ b/frontend/src/components/VaultDetails.vue @@ -40,6 +40,7 @@

{{ t('vaultDetails.sharedWith.title') }}

    + -
  • + +
+

{{ t('vaultDetails.actions.title') }}

-
+

+ {{ t('vaultDetails.error.paymentRequired') }} +

+ +
+ +

{{ t('vaultList.title') }}

@@ -123,6 +125,7 @@ import backend, { VaultDto } from '../common/backend'; import FetchError from './FetchError.vue'; import SlideOver from './SlideOver.vue'; import VaultDetails from './VaultDetails.vue'; +import LicenseAlert from './LicenseAlert.vue'; const { t } = useI18n({ useScope: 'global' }); diff --git a/frontend/src/i18n/en-US.json b/frontend/src/i18n/en-US.json index be4c5ca58..a48cd3e92 100644 --- a/frontend/src/i18n/en-US.json +++ b/frontend/src/i18n/en-US.json @@ -155,6 +155,11 @@ "initialSetup.accountKey": "Account Key", "initialSetup.submit": "Finish Setup", + "licenseAlert.title": "Attention needed", + "licenseAlert.noRemainingSeats": "Your Cryptomator Hub license has exceeded the number of available seats. {0} to renew it to manage and unlock your vaults again.", + "licenseAlert.licenseExpired": "Your Cryptomator Hub license has expired. {0} to renew it to manage and unlock your vaults again.", + "licenseAlert.button": "Open Admin Section", + "manageAccountKey.title": "Account Key", "manageAccountKey.description": "Your Account Key is required to login from other apps or browsers.", "manageAccountKey.regenerate": "Regenerate Key", From ab41cdf4896355b6c3e3193c6f5eb5a886116fe6 Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Tue, 13 Feb 2024 20:27:42 +0100 Subject: [PATCH 09/25] Show exceeding seats in Hub --- .../cryptomator/hub/api/BillingResource.java | 14 +++---- .../BillingResourceManagedInstanceTest.java | 4 +- .../hub/api/BillingResourceTest.java | 14 +++---- frontend/src/common/backend.ts | 4 +- frontend/src/components/AdminSettings.vue | 38 +++++++++++++------ frontend/src/components/LicenseAlert.vue | 17 +++++---- frontend/src/i18n/de-DE.json | 2 +- frontend/src/i18n/en-US.json | 2 +- 8 files changed, 55 insertions(+), 40 deletions(-) diff --git a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java index 1fb18c6db..b24827c95 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java @@ -65,25 +65,25 @@ public Response setToken(@NotNull @ValidJWS String token) { } public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("hasLicense") Boolean hasLicense, @JsonProperty("email") String email, - @JsonProperty("totalSeats") Integer totalSeats, @JsonProperty("remainingSeats") Integer remainingSeats, + @JsonProperty("licensedSeats") Integer licensedSeats, @JsonProperty("usedSeats") Integer usedSeats, @JsonProperty("issuedAt") Instant issuedAt, @JsonProperty("expiresAt") Instant expiresAt, @JsonProperty("managedInstance") Boolean managedInstance) { public static BillingDto create(String hubId, LicenseHolder licenseHolder) { - var seats = licenseHolder.getNoLicenseSeats(); - var remainingSeats = Math.max(seats - EffectiveVaultAccess.countSeatOccupyingUsers(), 0); + var licensedSeats = licenseHolder.getNoLicenseSeats(); + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); var managedInstance = licenseHolder.isManagedInstance(); - return new BillingDto(hubId, false, null, (int) seats, (int) remainingSeats, null, null, managedInstance); + return new BillingDto(hubId, false, null, (int) licensedSeats, (int) usedSeats, null, null, managedInstance); } public static BillingDto fromDecodedJwt(DecodedJWT jwt, LicenseHolder licenseHolder) { var id = jwt.getId(); var email = jwt.getSubject(); - var totalSeats = jwt.getClaim("seats").asInt(); - var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countSeatOccupyingUsers(), 0); + var licensedSeats = jwt.getClaim("seats").asInt(); + var usedSeats = (int) EffectiveVaultAccess.countSeatOccupyingUsers(); var issuedAt = jwt.getIssuedAt().toInstant(); var expiresAt = jwt.getExpiresAt().toInstant(); var managedInstance = licenseHolder.isManagedInstance(); - return new BillingDto(id, true, email, totalSeats, remainingSeats, issuedAt, expiresAt, managedInstance); + return new BillingDto(id, true, email, licensedSeats, usedSeats, issuedAt, expiresAt, managedInstance); } } diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java index c4a85708b..58490902e 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceManagedInstanceTest.java @@ -63,8 +63,8 @@ public void testGetEmptyManagedInstance() throws SQLException { .body("hubId", is("42")) .body("hasLicense", is(false)) .body("email", nullValue()) - .body("totalSeats", is(0)) - .body("remainingSeats", is(0)) + .body("licensedSeats", is(0)) + .body("usedSeats", is(2)) .body("issuedAt", nullValue()) .body("expiresAt", nullValue()); } diff --git a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceTest.java index dbea7086b..26718c282 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/BillingResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/BillingResourceTest.java @@ -62,8 +62,8 @@ public void testGetEmptySelfHosted() { .body("hubId", is("42")) .body("hasLicense", is(false)) .body("email", nullValue()) - .body("totalSeats", is(5)) //community license - .body("remainingSeats", is(3)) //depends on the flyway test data migration + .body("licensedSeats", is(5)) //community license + .body("usedSeats", is(2)) //depends on the flyway test data migration .body("issuedAt", nullValue()) .body("expiresAt", nullValue()); } @@ -86,8 +86,8 @@ public void testGetInitial() { .body("hubId", is("42")) .body("hasLicense", is(true)) .body("email", is("hub@cryptomator.org")) - .body("totalSeats", is(5)) - .body("remainingSeats", is(3)) + .body("licensedSeats", is(5)) + .body("usedSeats", is(2)) .body("issuedAt", is("2022-03-23T15:29:20Z")) .body("expiresAt", is("9999-12-31T00:00:00Z")); } @@ -109,8 +109,8 @@ public void testGetUpdated() { .body("hubId", is("42")) .body("hasLicense", is(true)) .body("email", is("hub@cryptomator.org")) - .body("totalSeats", is(5)) - .body("remainingSeats", is(3)) + .body("licensedSeats", is(5)) + .body("usedSeats", is(2)) .body("issuedAt", is("2022-03-23T15:43:30Z")) .body("expiresAt", is("9999-12-31T00:00:00Z")); } @@ -189,4 +189,4 @@ public void testPut() { } } -} \ No newline at end of file +} diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index bf03fe198..0196012cc 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -183,8 +183,8 @@ export type BillingDto = { hubId: string; hasLicense: boolean; email: string; - totalSeats: number; - remainingSeats: number; + licensedSeats: number; + usedSeats: number; issuedAt: Date; expiresAt: Date; managedInstance: boolean; diff --git a/frontend/src/components/AdminSettings.vue b/frontend/src/components/AdminSettings.vue index d9554d4a4..53b8f20e1 100644 --- a/frontend/src/components/AdminSettings.vue +++ b/frontend/src/components/AdminSettings.vue @@ -70,7 +70,7 @@
-
+

@@ -89,18 +89,18 @@
- -

+ +

-

+

@@ -133,7 +133,7 @@

-
+

@@ -156,18 +156,18 @@
- -

+ +

-

+

@@ -229,6 +229,20 @@ const betaUpdateExists = computed(() => { return false; }); +const remainingSeats = computed(() => { + if (admin.value) { + return admin.value.licensedSeats - admin.value.usedSeats; + } +}); +const numberOfExceededSeats = computed(() => { + if (remainingSeats.value != null && remainingSeats.value < 0) { + return Math.abs(remainingSeats.value); + } else if (remainingSeats.value != null) { + return 0; + } +}); + + onMounted(async () => { let cfg = config.get(); keycloakAdminRealmURL.value = `${cfg.keycloakUrl}/admin/${cfg.keycloakRealm}/console`; diff --git a/frontend/src/components/LicenseAlert.vue b/frontend/src/components/LicenseAlert.vue index d5cf308fc..b44518b68 100644 --- a/frontend/src/components/LicenseAlert.vue +++ b/frontend/src/components/LicenseAlert.vue @@ -1,12 +1,12 @@