Public Access
fix(settings): keep the language and save settings one after the other
This commit is contained in:
1 parent
14dd59f9e0
commit
f605917874
25 files changed
+423
-98
No files matched your search
+10
-8
@@ -1,4 +1,5 @@
|
||||
import {apiFetch} from './api.js';
|
||||
import {fetchMe, REQUEST_TIMEOUT_MS} from './settings.js';
|
||||
|
||||
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
||||
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
||||
@@ -12,7 +13,6 @@ export const DEFAULT_APPEARANCE = {
|
||||
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
|
||||
|
||||
const STORAGE_KEY = 'tbp_appearance';
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
export const normalizeAppearance = (value) => ({
|
||||
accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent,
|
||||
@@ -60,14 +60,16 @@ export const saveAppearance = (appearance) =>
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
|
||||
* GET /api/users/me on Auth, for its appearance (null when none is saved) and its language ('' when none is chosen).
|
||||
* Null when there is no answer to use.
|
||||
*/
|
||||
export const loadAppearance = async () => {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
// Saves wait for this load.
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
export const loadPreferences = async () => {
|
||||
// Saves wait for this load, so it has a timeout.
|
||||
const response = await fetchMe();
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null;
|
||||
return {
|
||||
appearance: data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null,
|
||||
language: data.settings?.language ?? '',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import {apiFetch} from './api.js';
|
||||
|
||||
export const LANGUAGES = ['en', 'de'];
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'tbp_language';
|
||||
export const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
export const readCachedLanguage = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
return LANGUAGES.includes(stored) ? stored : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const cacheLanguage = (language) => {
|
||||
try {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
} catch {
|
||||
// Private mode or a full storage: the language is only not cached.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/users/me on Auth. The caller handles the response status.
|
||||
*/
|
||||
export const fetchMe = () => apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
let queue = Promise.resolve();
|
||||
|
||||
/**
|
||||
* PUT /api/users/settings on Auth with `changes`. That endpoint replaces all four fields, so each save first loads
|
||||
* the saved ones; saves run one after another, from every page. The caller handles the response status.
|
||||
*/
|
||||
export const saveSettings = (changes) => {
|
||||
const save = async () => {
|
||||
const me = await fetchMe();
|
||||
if (!me.ok) return me;
|
||||
const data = await me.json();
|
||||
return apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
useDarkTheme: !!data.settings?.useDarkTheme,
|
||||
language: data.settings?.language ?? '',
|
||||
displayName: data.displayName ?? null,
|
||||
publicProfile: !!data.settings?.publicProfile,
|
||||
...changes,
|
||||
}),
|
||||
// A hung request must not hold back the next saves.
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
};
|
||||
const result = queue.then(save, save);
|
||||
queue = result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import {cacheLanguage, readCachedLanguage} from './settings.js';
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe('language cache', () => {
|
||||
it('gives back the cached language, and nothing for an unknown one', () => {
|
||||
expect(readCachedLanguage()).toBeNull();
|
||||
cacheLanguage('en');
|
||||
expect(readCachedLanguage()).toBe('en');
|
||||
localStorage.setItem('tbp_language', 'fr');
|
||||
expect(readCachedLanguage()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,11 @@ const emailField = z
|
||||
.min(1, 'VALIDATION.EMAIL_REQUIRED')
|
||||
.email('VALIDATION.EMAIL_INVALID');
|
||||
|
||||
// Auth hashes with BCrypt, which takes at most 72 bytes.
|
||||
const passwordField = z
|
||||
.string()
|
||||
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT');
|
||||
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT')
|
||||
.refine((value) => new TextEncoder().encode(value).length <= 72, 'VALIDATION.PASSWORD_TOO_LONG');
|
||||
|
||||
const inviteCodeField = z
|
||||
.string()
|
||||
@@ -40,7 +42,5 @@ export const registrationSchema = z
|
||||
});
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
useDarkTheme: z.boolean(),
|
||||
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
|
||||
displayName: displayNameField,
|
||||
});
|
||||
@@ -29,3 +29,12 @@ describe('displayName validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('registration password', () => {
|
||||
it('rejects a password over 72 UTF-8 bytes, as Auth does', () => {
|
||||
const password = 'é'.repeat(37);
|
||||
expect(registrationSchema.safeParse({
|
||||
email: 'a@b.de', password, passwordConfirmation: password, inviteCode: 'code',
|
||||
}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user