Skip to content

Commit

Permalink
Merge pull request #313 from cryptomator/feature/store-language-setting
Browse files Browse the repository at this point in the history
Preserve User Language Setting
  • Loading branch information
SailReal authored Feb 7, 2025
2 parents 52620c5 + e0a09e9 commit 00e9324
Show file tree
Hide file tree
Showing 13 changed files with 99 additions and 30 deletions.
8 changes: 8 additions & 0 deletions .crowdin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
commit_message: '[ci skip]'
files:
- source: /frontend/src/i18n/en-US.json
translation: /frontend/src/i18n/%locale%.json
skip_untranslated_strings: false
skip_untranslated_files: true
escape_quotes: 0
escape_special_characters: 0
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- WoT: Users can now mutually verify their identity, hardening Hub against injection of malicious public keys (#281)
- WoT: Admins can adjust WoT parameters (#297)
- Permission to create new vaults can now be controlled via the `create-vaults` role in Keycloak (#206)
- Preserver user locale setting (#313)

### Changed

Expand Down
7 changes: 5 additions & 2 deletions backend/src/main/java/org/cryptomator/hub/api/UserDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public final class UserDto extends AuthorityDto {

@JsonProperty("email")
public final String email;
@JsonProperty("language")
public final String language;
@JsonProperty("devices")
public final Set<DeviceResource.DeviceDto> devices;
@JsonProperty("ecdhPublicKey")
Expand All @@ -31,10 +33,11 @@ public final class UserDto extends AuthorityDto {
@JsonProperty("publicKey")
public final String legacyEcdhPublicKey;

UserDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("email") String email, @JsonProperty("devices") Set<DeviceResource.DeviceDto> devices,
UserDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("email") String email, @JsonProperty("language") String language, @JsonProperty("devices") Set<DeviceResource.DeviceDto> devices,
@Nullable @JsonProperty("ecdhPublicKey") @OnlyBase64Chars String ecdhPublicKey, @Nullable @JsonProperty("ecdsaPublicKey") @OnlyBase64Chars String ecdsaPublicKey, @Nullable @JsonProperty("privateKeys") @ValidJWE String privateKeys, @Nullable @JsonProperty("setupCode") @ValidJWE String setupCode) {
super(id, Type.USER, name, pictureUrl);
this.email = email;
this.language = language;
this.devices = devices;
this.ecdhPublicKey = ecdhPublicKey;
this.ecdsaPublicKey = ecdsaPublicKey;
Expand All @@ -46,6 +49,6 @@ public final class UserDto extends AuthorityDto {
}

public static UserDto justPublicInfo(User user) {
return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), Set.of(), user.getEcdhPublicKey(), user.getEcdsaPublicKey(),null, null);
return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), user.getLanguage(), Set.of(), user.getEcdhPublicKey(), user.getEcdsaPublicKey(),null, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public Response putMe(@Nullable @Valid UserDto dto) {
eventLogger.logUserKeysChanged(jwt.getSubject(), jwt.getName());
}
updateDevices(user, dto);
user.setLanguage(dto.language);
}
userRepo.persist(user);
return Response.created(URI.create(".")).build();
Expand Down Expand Up @@ -157,7 +158,7 @@ public UserDto getMe(@QueryParam("withDevices") boolean withDevices) {
User user = userRepo.findById(jwt.getSubject());
Function<Device, DeviceResource.DeviceDto> mapDevices = d -> new DeviceResource.DeviceDto(d.getId(), d.getName(), d.getType(), d.getPublickey(), d.getUserPrivateKeys(), d.getOwner().getId(), d.getCreationTime().truncatedTo(ChronoUnit.MILLIS));
var devices = withDevices ? user.devices.stream().map(mapDevices).collect(Collectors.toSet()) : Set.<DeviceResource.DeviceDto>of();
return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), devices, user.getEcdhPublicKey(), user.getEcdsaPublicKey(), user.getPrivateKeys(), user.getSetupCode());
return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), user.getLanguage(), devices, user.getEcdhPublicKey(), user.getEcdsaPublicKey(), user.getPrivateKeys(), user.getSetupCode());
}

@POST
Expand Down
11 changes: 11 additions & 0 deletions backend/src/main/java/org/cryptomator/hub/entities/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ public class User extends Authority {
@Column(name = "email")
private String email;

@Column(name = "language")
private String language;

@Column(name = "ecdh_publickey")
private String ecdhPublicKey;

Expand Down Expand Up @@ -77,6 +80,14 @@ public void setEmail(String email) {
this.email = email;
}

public String getLanguage() {
return language;
}

public void setLanguage(String language) {
this.language = language;
}

public String getEcdhPublicKey() {
return ecdhPublicKey;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "user_details" add "language" VARCHAR(2);
1 change: 1 addition & 0 deletions frontend/src/common/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export type UserDto = {
name: string;
pictureUrl?: string;
email: string;
language?: string;
devices: DeviceDto[];
accessibleVaults: VaultDto[];
ecdhPublicKey?: string;
Expand Down
24 changes: 12 additions & 12 deletions frontend/src/components/UnlockSuccess.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,47 +17,47 @@
<img src="/logo.svg" class="h-12" alt="Logo" aria-hidden="true" />
</div>

<!-- TODO: localize -->

<!-- ACCOUNT SETUP -->
<div v-if="accountState == AccountState.RequiresSetup" class="text-sm text-gray-500">
<h1 class="text-2xl leading-6 font-medium text-gray-900">
Setup Required
{{ t('unlockSuccess.accountSetup.title') }}
</h1>
<p class="my-3">
Complete setting up your account and retrieve your account key.
{{ t('unlockSuccess.accountSetup.description') }}
</p>
<router-link to="/app/setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-xs text-white bg-primary focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary">Complete Setup</router-link>
<router-link to="/app/setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-xs text-white bg-primary focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary">{{ t('unlockSuccess.accountSetup.goToSetup') }}</router-link>
</div>

<!-- DEVICE SETUP -->
<div v-else-if="deviceState == DeviceState.NoSuchDevice" class="text-sm text-gray-500">
<h1 class="text-2xl leading-6 font-medium text-gray-900">
New Device
{{ t('unlockSuccess.deviceSetup.title') }}
</h1>
<p class="my-3">
Please enter your account key in Cryptomator to authorize it.
{{ t('unlockSuccess.deviceSetup.description') }}
</p>
<router-link v-if="hasBrowserKeys" to="/app/profile" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-xs text-white bg-primary focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary">View Account Key in my Profile</router-link>
<router-link v-if="hasBrowserKeys" to="/app/profile" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-xs text-white bg-primary focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary">
{{ t('unlockSuccess.deviceSetup.goToProfile') }}
</router-link>
</div>

<!-- NO VAULT ACCESS -->
<div v-else-if="vaultAccess == VaultAccess.Denied" class="text-sm text-gray-500">
<h1 class="text-2xl leading-6 font-medium text-gray-900">
No access to this Vault
{{ t('unlockSuccess.noVaultAccess.title') }}
</h1>
<p class="mt-2">
Please contact the vault owner to add you as a vault member.
{{ t('unlockSuccess.noVaultAccess.description') }}
</p>
</div>

<!-- SUCCESS -->
<div v-else class="text-sm text-gray-500">
<h1 class="text-2xl leading-6 font-medium text-gray-900">
Vault unlocked successfully
{{ t('unlockSuccess.title') }}
</h1>
<p class="mt-2">
You may now close this browser tab and return to Cryptomator.
{{ t('unlockSuccess.description') }}
</p>
</div>
</div>
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/components/UserProfile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
</ListboxButton>
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<ListboxOptions class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 focus:outline-hidden text-sm">
<ListboxOption v-for="locale in Locale" :key="locale" v-slot="{ active, selected }" class="relative cursor-default select-none py-2 pl-3 pr-9 ui-not-active:text-gray-900 ui-active:text-white ui-active:bg-primary" :value="locale">
<ListboxOption v-for="locale in Locale" :key="locale" v-slot="{ active, selected }" class="relative cursor-default select-none py-2 pl-3 pr-9 ui-not-active:text-gray-900 ui-active:text-white ui-active:bg-primary" :value="locale" @click="saveLanguage(locale)">
<span :class="[selected ? 'font-semibold' : 'font-normal', 'block truncate']">{{ t(`locale.${locale}`) }}</span>
<span v-if="selected" :class="[active ? 'text-white' : 'text-primary', 'absolute inset-y-0 right-0 flex items-center pr-4']">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
Expand Down Expand Up @@ -99,6 +99,15 @@ async function fetchData() {
}
}
async function saveLanguage(locale: Locale) {
const updatedUser = me.value;
if (updatedUser !== undefined) {
updatedUser.language = locale.toString();
await backend.users.putMe(updatedUser);
me.value = updatedUser;
}
}
function openKeycloakUserAccount() {
window.open(keycloakUserAccountURL.value, '_blank');
}
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,17 @@
"userProfile.actions.manageAccount": "Manage Account",
"userProfile.actions.changeLanguage": "Change Language",

"unlockSuccess.title": "Vault unlocked successfully",
"unlockSuccess.description": "You may now close this browser tab and return to Cryptomator.",
"unlockSuccess.accountSetup.title": "Setup Required",
"unlockSuccess.accountSetup.description": "Complete setting up your account and retrieve your account key.",
"unlockSuccess.accountSetup.goToSetup": "Complete Setup",
"unlockSuccess.deviceSetup.title": "New Device",
"unlockSuccess.deviceSetup.description": "Please enter your account key in Cryptomator to authorize it.",
"unlockSuccess.deviceSetup.goToProfile": "View Account Key in my Profile",
"unlockSuccess.noVaultAccess.title": "No access to this Vault",
"unlockSuccess.noVaultAccess.description": "Please contact the vault owner to add you as a vault member.",

"vaultDetails.warning.archived": "This vault is archived and cannot be unlocked.",
"vaultDetails.description.header": "Description",
"vaultDetails.description.empty": "No description provided.",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { I18nOptions } from 'vue-i18n';
import de from './de-DE.json';
import en from './en-US.json';

import { createI18n } from 'vue-i18n';

// ISO 639‑1 two letter code
export enum Locale {
EN = 'en',
DE = 'de'
Expand Down Expand Up @@ -37,3 +40,22 @@ export const numberFormats: I18nOptions['numberFormats'] = {
}
}
};

export const mapToLocale = (local: string): Locale =>
(Object.values(Locale) as string[]).includes(local)
? (local as Locale)
: Locale.EN;

const i18n = createI18n({
locale: navigator.language,
fallbackLocale: Locale.EN,
messages,
datetimeFormats,
numberFormats,
globalInjection: true,
missingWarn: false,
fallbackWarn: false,
legacy: false
});

export default i18n;
15 changes: 2 additions & 13 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import App from './App.vue';
import './css/fonts.css';
import { Locale, datetimeFormats, messages, numberFormats } from './i18n/index';
import i18n from './i18n';
import './index.css';
import router from './router';
// migrate to // import messages from '@intlify/vite-plugin-vue-i18n/messages'; as soon as it works

const i18n = createI18n({
locale: navigator.language,
fallbackLocale: Locale.EN,
messages,
datetimeFormats,
numberFormats,
globalInjection: true,
missingWarn: false,
fallbackWarn: false
});
// migrate to // import messages from '@intlify/vite-plugin-vue-i18n/messages'; as soon as it works

createApp(App)
.use(router)
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import AdminSettings from '../components/AdminSettings.vue';
import AuditLog from '../components/AuditLog.vue';
import AuthenticatedMain from '../components/AuthenticatedMain.vue';
import CreateVault from '../components/CreateVault.vue';
import Forbidden from '../components/Forbidden.vue';
import InitialSetup from '../components/InitialSetup.vue';
import NotFound from '../components/NotFound.vue';
import UnlockError from '../components/UnlockError.vue';
import UnlockSuccess from '../components/UnlockSuccess.vue';
import UserProfile from '../components/UserProfile.vue';
import VaultDetails from '../components/VaultDetails.vue';
import VaultList from '../components/VaultList.vue';
import Forbidden from '../components/Forbidden.vue';

import i18n, { mapToLocale } from '../i18n';

function checkRole(role: string): NavigationGuardWithThis<undefined> {
return async (to, _) => {
Expand Down Expand Up @@ -193,4 +195,14 @@ router.beforeEach(async (to) => {
}
});

// FOURTH apply user language
router.beforeEach(async (to) => {
if (!to.meta.skipAuth) {
const me = await userdata.me;
if (me.language) {
i18n.global.locale.value = mapToLocale(me.language);
}
}
});

export default router;

0 comments on commit 00e9324

Please sign in to comment.