feat: account editing on web

This commit is contained in:
limes
2026-08-20 19:48:22 +02:00
parent 31e69b1376
commit 06d9c6f3c9
14 changed files with 58965 additions and 259 deletions

View File

@@ -62,3 +62,7 @@ There are no fully required configuration variables, the app can runs minimally
| | `PN_WEBSITE_PUBLIC_HCAPTCHA_SITE_KEY` | HCaptcha site key | - |
| | | | |
| Discourse SSO | `PN_WEBSITE_DISCOURSE_SSO_SECRET` | Discourse SSO secret | - |
TODO:
account settings error handling

9
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": {
"@discordjs/rest": "^2.6.3",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@internationalized/date": "^3.12.3",
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
"@nuxt/fonts": "^0.14.0",
@@ -18,7 +19,7 @@
"@nuxtjs/i18n": "^10.6.0",
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/error-codes": "^1.2.2",
"@pretendonetwork/grpc": "^2.5.4",
"@pretendonetwork/grpc": "^2.5.7",
"@pretendonetwork/mii-js": "^1.0.11",
"@vueuse/core": "^14.4.0",
"better-sqlite3": "^12.11.1",
@@ -5346,9 +5347,9 @@
}
},
"node_modules/@pretendonetwork/grpc": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.4.tgz",
"integrity": "sha512-spjg6sOSP8z+9T9vqR9RM5guJb7UHYQlFBfb6p6WxpaYHsXlmVIRPeQlR7NTkgpNzARV/4QVpqerBbtLDvN4oA==",
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.7.tgz",
"integrity": "sha512-HmWyBxm/Om6S5k+PYLxVU/MaRcSUEV0NAfKccy4i02ZJTZccCJRRKiSBl3WfXSYE+TJNJhSoURGK56E1i8CXqA==",
"license": "AGPL-3.0-only",
"dependencies": {
"@bufbuild/protobuf": "^2.2.2",

View File

@@ -17,6 +17,7 @@
"dependencies": {
"@discordjs/rest": "^2.6.3",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@internationalized/date": "^3.12.3",
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
"@nuxt/fonts": "^0.14.0",
@@ -24,7 +25,7 @@
"@nuxtjs/i18n": "^10.6.0",
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/error-codes": "^1.2.2",
"@pretendonetwork/grpc": "^2.5.4",
"@pretendonetwork/grpc": "^2.5.7",
"@pretendonetwork/mii-js": "^1.0.11",
"@vueuse/core": "^14.4.0",
"better-sqlite3": "^12.11.1",

View File

@@ -3,17 +3,14 @@ import { AccountUpdateSchema } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<void> => {
const body = await readZodBody(event, AccountUpdateSchema);
const auth = enforceLoggedIn(event);
const apiFetch = useHttpApi(event, auth.token);
const grpc = useApiGrpcWithToken(event, auth.token);
// There's no equivalent GRPC endpoint to use, so we're using the HTTP api
await apiFetch('/v1/user', {
method: 'POST',
body: JSON.stringify({
mii: body.mii,
environment: body.environment
}),
headers: {
'Content-type': 'application/json'
}
await grpc.updateUserData({
gender: body.gender,
birthday: body.birthday,
region: body.region,
timezone: body.timezone,
mii: body.mii,
serverAccessLevel: body.serverAccessLevel
});
});

View File

@@ -13,6 +13,7 @@ export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
birthday: data.birthday,
gender: data.gender,
country: data.country,
region: data.region,
timezone: data.timezone,
emailAddress: data.emailAddress,
serverAccessLevel: data.serverAccessLevel as any,

View File

@@ -53,6 +53,6 @@ export function useLegacyApiGrpcWithToken(event: H3Event, token: string): Client
return getGrpcClient(event, APIDefinition, token);
}
export function useApiGrpcWithToken(event: H3Event, token: string): Client<APIDefinition> {
return getGrpcClient(event, APIDefinition, token);
export function useApiGrpcWithToken(event: H3Event, token: string): Client<ApiServiceDefinition> {
return getGrpcClient(event, ApiServiceDefinition, token);
}

View File

@@ -7,6 +7,7 @@ export type GetApiAuthMe = {
birthday: string;
gender: string;
country: string;
region: number;
timezone: string;
emailAddress: string;
serverAccessLevel: 'dev' | 'test' | 'prod';
@@ -107,12 +108,12 @@ export const RegisterSchema = z.object({
export type ApiAuthRegisterRequest = z.infer<typeof RegisterSchema>;
export const AccountUpdateSchema = z.object({
mii: z.object({
name: z.string(),
primary: z.enum(['Y', 'N']),
data: z.string()
}).optional(),
environment: z.enum(['prod', 'test', 'dev']).optional()
birthday: z.string().optional(),
mii: z.string().optional(),
serverAccessLevel: z.enum(['prod', 'test', 'dev']).optional(),
gender: z.enum(['F', 'M']).optional(),
region: z.number().optional(),
timezone: z.string().optional()
});
export type ApiAccountUpdateRequest = z.infer<typeof AccountUpdateSchema>;

View File

@@ -138,7 +138,7 @@ div.modal-wrapper {
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.75);
z-index: 100;
}
@@ -146,11 +146,12 @@ div.modal-wrapper.hidden {
display: none;
}
div.modal {
background: var(--bg-shade-3);
background: var(--bg-shade-1);
padding: 48px;
border-radius: 8px;
text-align: left;
width: min(660px, 90%);
max-width: min(660px, 90%);
width: 100vw;
box-sizing: border-box;
margin: auto;
}

43687
src/assets/json/regions.json Normal file

File diff suppressed because it is too large Load Diff

14121
src/assets/json/timezones.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -223,7 +223,8 @@
"nickname": "Nickname",
"birthDate": "Birth date",
"gender": "Gender",
"country": "Country/region",
"country": "Country",
"region": "Region",
"timezone": "Timezone",
"serverEnv": "Server environment",
"production": "Production",

File diff suppressed because it is too large Load Diff

View File

@@ -285,7 +285,7 @@ const { isLoading: isSaving, execute } = useAsync({
return apiFetch('/api/account/update', {
method: 'PATCH',
body: {
mii: { name: mii.value.miiName, primary: 'Y', data: mii.value.encode().toString('base64') }
mii: mii.value.encode().toString('base64')
} satisfies ApiAccountUpdateRequest
});
},

View File

@@ -0,0 +1,155 @@
import regions from '@/assets/json/regions.json';
import timezones from '@/assets/json/timezones.json';
export function webLocaleToConsoleLocale(lo: string): 'japanese' | 'french' | 'german' | 'italian' | 'spanish' | 'korean' | 'dutch' | 'portuguese' | 'russian' | 'chinese_traditional' | 'chinese_simple' | 'english' {
const convObj = {
'ja': 'japanese',
'fr': 'french',
'de': 'german',
'it': 'italian',
'es': 'spanish',
'ko': 'korean',
'nl': 'dutch',
'pt': 'portuguese',
'ru': 'russian',
'zh-Hant': 'chinese_traditional',
'zh-Hans': 'chinese_simple'
};
const l = lo.split('-');
return convObj[l[0]] || convObj[lo] || 'english';
}
export function webLocaleToTimezoneLocale(lo: string) {
const l = lo.split('-');
switch (l[0]) {
case 'ja':
case 'fr':
case 'de':
case 'it':
case 'es':
case 'nl':
case 'pt':
case 'ru': {
return l[0];
}
default: {
return 'en';
}
}
}
export function regionIdToCountryObject(region: number) {
const countryId = (region >>> 24) & 0xFF;
return regions.find(r => r.id === countryId);
}
export function regionIdToObject(region: number) {
return regions.flatMap(c => c.regions).find(r => r.id === region);
}
export function regionIdToLocalizedNames(localeCode: string, region: number | undefined) {
if (!localeCode || !region) {
return;
}
const l = webLocaleToConsoleLocale(localeCode);
const lCountryName = regionIdToCountryObject(region)?.translations[l];
const lRegionName = regionIdToObject(region)?.translations[l];
return {
country: lCountryName,
region: lRegionName
};
}
export function getLocalizedRegionTimezones(localeCode: string, region: number | undefined) {
if (!localeCode || !region) {
return;
}
const country = regionIdToCountryObject(region);
const l = webLocaleToTimezoneLocale(localeCode);
const countryTimezones = timezones[country.iso_code];
return countryTimezones[l] || countryTimezones.en || countryTimezones.ja;
}
export function getLocalizedTimezoneString(localeCode: string | undefined, region: number | undefined, timezone: string | undefined) {
if (!localeCode || !region || !timezone) {
return;
}
const tList = getLocalizedRegionTimezones(localeCode, region);
let localizedTz = '';
const tzObj = tList.find((v) => {
return (v.area === timezone);
});
if (tzObj) {
localizedTz = `${timezone?.replaceAll('_', ' ')} - ${tzObj.name}`;
}
return localizedTz;
}
export function getLocalizedCountryList(localeCode: string) {
const l = webLocaleToConsoleLocale(localeCode);
const lCountryList: {
code: string;
id: number;
name: string;
}[] = [];
regions.forEach(
(r) => {
lCountryList.push({
code: r.iso_code,
id: r.id,
name: r.translations[l] || r.translations.english || r.name || r.translations.japanese
});
}
);
lCountryList.sort((a, b) => {
return a.name.localeCompare(b.name);
});
return lCountryList;
}
export function getLocalizedRegionList(localeCode: string, region: number | undefined) {
if (!localeCode || !region) {
return;
}
const l = webLocaleToConsoleLocale(localeCode);
const c = regionIdToCountryObject(region);
const lRegionList: {
id: number;
name: string;
}[] = [];
c?.regions.forEach(
(r) => {
lRegionList.push({
id: r.id,
name: r.translations[l] || r.translations.english || r.name || r.translations.japanese
});
}
);
lRegionList.sort((a, b) => {
return a.name.localeCompare(b.name);
});
return lRegionList;
}