Merge pull request #450 from PretendoNetwork/feat/account-editing
Some checks failed
Build and Publish Docker Image / Build and Publish (amd64) (push) Has been cancelled
Build and Publish Docker Image / Build and Publish (arm64) (push) Has been cancelled

feat: account editing on web
This commit is contained in:
limes.pink
2026-08-25 00:36:33 +02:00
committed by GitHub
16 changed files with 59098 additions and 269 deletions

View File

@@ -67,7 +67,7 @@ services:
volumes:
- "./assets/garage-init.sh:/etc/init.sh"
account:
image: ghcr.io/pretendonetwork/account:sha-edb4b02
image: ghcr.io/pretendonetwork/account:sha-81cbacf
restart: unless-stopped
networks:
- net

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

@@ -1,19 +1,48 @@
import { ClientError } from 'nice-grpc';
import { AccountUpdateSchema } from '~~/shared/api-types';
import type { ApiErrorCodes } from '~~/shared/errors';
const bucket = createRatelimitBucket({
id: 'account-update',
points: 100,
durationSec: 5 * 60, // 5 minutes
blockDurationSec: 1 * 60 * 60 // 1 hour
});
const errors: Record<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: Must be one of: prod, test, dev': 'INVALID_ACCESS_LEVEL',
'PERMISSION_DENIED: Banned': 'BANNED',
'INVALID_ARGUMENT: Do not have permission to enter this environment': 'INSUFFICIENT_ACCESS_LEVEL',
'INVALID_ARGUMENT: Must be a valid date formatted as: YYYY-MM-DD': 'INVALID_DATE',
'INVALID_ARGUMENT: Must be one of: F, M': 'INVALID_GENDER',
'INVALID_ARGUMENT: Invalid region': 'INVALID_REGION',
'INVALID_ARGUMENT: Invalid timezone': 'INVALID_TIMEZONE',
'INVALID_ARGUMENT: Invalid mii data': 'INVALID_MII_DATA'
};
export default defineEventHandler(async (event): Promise<void> => {
await enforceRatelimit(event, bucket);
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({
try {
await grpc.updateUserData({
gender: body.gender,
birthday: body.birthday,
region: body.region,
timezone: body.timezone,
mii: body.mii,
environment: body.environment
}),
headers: {
'Content-type': 'application/json'
serverAccessLevel: body.serverAccessLevel
});
} catch (error: unknown) {
if (error instanceof ClientError) {
const errorCode = errors[error.details];
if (errorCode) {
throw createApiError(errorCode);
}
}
});
throw error;
}
});

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

@@ -18,7 +18,15 @@ const apiErrorCodes = {
MIINAME_TOO_LONG: 'Mii name too long',
INVALID_PASSWORD_INPUT: 'Password must be between 6 and 16 characters long',
INVALID_PASSWORD_NO_MATCH: 'Passwords do not match',
ACCOUNT_DELETED: 'Account has been deleted'
ACCOUNT_DELETED: 'Account has been deleted',
INVALID_ACCESS_LEVEL: 'Invalid access level',
BANNED: 'Account is banned',
INSUFFICIENT_ACCESS_LEVEL: 'Do not have permission to enter this environment',
INVALID_DATE: 'Invalid date',
INVALID_GENDER: 'Invalid gender',
INVALID_REGION: 'Invalid region',
INVALID_TIMEZONE: 'Invalid timezone',
INVALID_MII_DATA: 'Invalid mii data'
} as const;
export type ApiErrorCodes = keyof typeof apiErrorCodes;
@@ -43,7 +51,15 @@ export const apiErrorCodeStatus: Record<ApiErrorCodes, number> = {
USERNAME_IN_USE: 400,
USERNAME_INVALID_CHARS: 400,
USERNAME_TOO_LONG: 400,
USERNAME_TOO_SHORT: 400
USERNAME_TOO_SHORT: 400,
INVALID_ACCESS_LEVEL: 400,
BANNED: 403,
INSUFFICIENT_ACCESS_LEVEL: 403,
INVALID_DATE: 400,
INVALID_GENDER: 400,
INVALID_REGION: 400,
INVALID_TIMEZONE: 400,
INVALID_MII_DATA: 400
};
export function getTextForApiErrorCode(code: ApiErrorCodes): string {

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

@@ -0,0 +1,749 @@
<script setup lang="ts">
import { Label, Dialog, Select, DatePicker } from 'reka-ui/namespaced';
import { parseDate } from '@internationalized/date';
import {
getLocalizedRegionTimezones,
getLocalizedCountryList,
getLocalizedRegionList,
regionIdToCountryId,
countryIdToUndefinedRegionId
} from '@/utils/localizeConsole';
import type { ApiAccountUpdateRequest, GetApiAuthMe } from '~~/shared/api-types';
const toasts = useToasts();
const { locale } = useI18n();
const { profile } = defineProps<{
profile: GetApiAuthMe;
dialogContainer: HTMLElement | null;
}>();
const open = defineModel<boolean>();
const emit = defineEmits<{
change: [];
}>();
const newBirthday = shallowRef(parseDate(profile.birthday || ''));
const newCountry = ref(regionIdToCountryId(profile.region || 0));
const newRegion = ref(profile.region);
const newTimezone = ref(profile.timezone);
const newLocalizedTimezoneList = computed(() => {
return getLocalizedRegionTimezones(locale.value, newRegion.value);
});
const localizedCountryList = computed(() => {
return getLocalizedCountryList(locale.value);
});
const newLocalizedRegionList = computed(() => {
return getLocalizedRegionList(locale.value, newRegion.value);
});
watch(newCountry, () => {
// if a new country has been picked, set the region to its 'Undefined' region.
newRegion.value = countryIdToUndefinedRegionId(newCountry.value);
});
watch(newRegion, (o, n) => {
if (regionIdToCountryId(o) === regionIdToCountryId(n)) {
// same country so we leave the timezone as is, else we might override the user's choice.
return;
}
newTimezone.value = newLocalizedTimezoneList.value?.[0]?.area || '';
});
const { execute: executeUpdateUserData, isLoading: isLoadingUpdateUserData } =
useAsync({
async handler() {
const updateBody: ApiAccountUpdateRequest = {
birthday: newBirthday.value.toString(),
region: newRegion.value,
timezone: newTimezone.value
};
await apiFetch('/api/account/update', {
method: 'PATCH',
body: updateBody
});
open.value = false;
emit('change');
},
onError(error) {
const err = getApiError(error);
toasts.publish({
type: 'error',
text: err.message
});
}
});
</script>
<template>
<Dialog.Root v-model:open="open">
<Dialog.Trigger as-child>
<slot />
</Dialog.Trigger>
<Dialog.Portal :to="dialogContainer ?? undefined">
<Dialog.Overlay />
<Dialog.Content
class="modal"
@interact-outside="(e) => {
if (isLoadingUpdateUserData) {
return e.preventDefault();
}
}"
>
<Dialog.Title>
{{ $t("account.settings.settingCards.userSettings") }}.
</Dialog.Title>
<div class="fieldsets-container">
<fieldset class="account-edit">
<Label
class="birthday"
for="birthday"
>
{{ $t("account.settings.settingCards.birthDate") }}</Label>
<DatePicker.Root
v-model="newBirthday"
:locale="locale"
granularity="day"
close-on-select
>
<DatePicker.Field
v-slot="{ segments }"
class="date-field"
>
<template
v-for="item in segments"
:key="item.part"
>
<DatePicker.Input
v-if="item.part === 'literal'"
:part="item.part"
class="date-field-literal"
>
{{ item.value }}
</DatePicker.Input>
<DatePicker.Input
v-else
:part="item.part"
class="date-field-segment"
>
{{ item.value }}
</DatePicker.Input>
</template>
<DatePicker.Trigger class="calendar-popover-trigger">
<Icon
name="ph:calendar-dots"
class="date-icon"
/>
</DatePicker.Trigger>
</DatePicker.Field>
<DatePicker.Content
align="end"
class="calendar-popover-content"
:portal="{
disabled: true
}"
>
<DatePicker.Arrow class="calendar-popover-arrow" />
<DatePicker.Calendar
v-slot="{ weekDays, grid }"
class="calendar"
>
<DatePicker.Header class="calendar-header">
<DatePicker.Prev
class="calendar-nav-button"
>
<Icon
name="ph:caret-left"
class="date-icon"
/>
</DatePicker.Prev>
<DatePicker.Heading class="calendar-heading" />
<DatePicker.Next
class="calendar-nav-button"
>
<Icon
name="ph:caret-right"
class="date-icon"
/>
</DatePicker.Next>
</DatePicker.Header>
<div
class="calendar-wrapper"
>
<DatePicker.Grid
v-for="month in grid"
:key="month.value.toString()"
class="calendar-grid"
>
<DatePicker.GridHead>
<DatePicker.GridRow class="calendar-grid-row">
<DatePicker.HeadCell
v-for="day in weekDays"
:key="day"
class="calendar-head-cell"
>
{{ day }}
</DatePicker.HeadCell>
</DatePicker.GridRow>
</DatePicker.GridHead>
<DatePicker.GridBody>
<DatePicker.GridRow
v-for="(weekDates, index) in month.rows"
:key="`weekDate-${index}`"
class="calendar-grid-row"
>
<DatePicker.Cell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
class="calendar-cell"
>
<DatePicker.CellTrigger
:day="weekDate"
:month="month.value"
class="calendar-cell-trigger"
/>
</DatePicker.Cell>
</DatePicker.GridRow>
</DatePicker.GridBody>
</DatePicker.Grid>
</div>
</DatePicker.Calendar>
</DatePicker.Content>
</DatePicker.Root>
</fieldset>
<fieldset class="account-edit">
<Label
class="country"
for="country"
>
{{ $t("account.settings.settingCards.country") }}</Label>
<Select.Root v-model="newCountry">
<Select.Trigger
class="select-trigger"
aria-label="Select country"
>
<Select.Value />
<Icon name="ph:caret-down" />
</Select.Trigger>
<Select.Content
class="select-content"
position="popper"
>
<Select.ScrollUpButton class="select-scrollbutton">
<Icon name="ph:caret-up" />
</Select.ScrollUpButton>
<Select.Viewport class="select-viewport">
<Select.Item
v-for="c in localizedCountryList"
:key="c.id"
class="select-item"
:value="c.id"
>
<Select.ItemIndicator class="select-itemindicator">
<Icon name="ph:check" />
</Select.ItemIndicator>
<Select.ItemText>
{{ c.name }}
</Select.ItemText>
</Select.Item>
</Select.Viewport>
<Select.ScrollDownButton class="select-scrollbutton">
<Icon name="ph:caret-down" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Root>
</fieldset>
<fieldset class="account-edit">
<Label
class="region"
for="region"
>{{
$t("account.settings.settingCards.region")
}}</Label>
<Select.Root v-model="newRegion">
<Select.Trigger
class="select-trigger"
aria-label="Select region"
>
<Select.Value />
<Icon name="ph:caret-down" />
</Select.Trigger>
<Select.Content
class="select-content"
position="popper"
>
<Select.ScrollUpButton class="select-scrollbutton">
<Icon name="ph:caret-up" />
</Select.ScrollUpButton>
<Select.Viewport class="select-viewport">
<Select.Item
v-for="r in newLocalizedRegionList"
:key="r.id"
class="select-item"
:value="r.id"
>
<Select.ItemIndicator class="select-itemindicator">
<Icon name="ph:check" />
</Select.ItemIndicator>
<Select.ItemText>
{{ r.name }}
</Select.ItemText>
</Select.Item>
</Select.Viewport>
<Select.ScrollDownButton class="select-scrollbutton">
<Icon name="ph:caret-down" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Root>
</fieldset>
<fieldset class="account-edit">
<Label
class="timezone"
for="timezone"
>
{{ $t("account.settings.settingCards.timezone") }}</Label>
<Select.Root v-model="newTimezone">
<Select.Trigger
class="select-trigger"
aria-label="Select timezone"
>
<Select.Value />
<Icon name="ph:caret-down" />
</Select.Trigger>
<Select.Content
class="select-content"
position="popper"
>
<Select.ScrollUpButton class="select-scrollbutton">
<Icon name="ph:caret-up" />
</Select.ScrollUpButton>
<Select.Viewport class="select-viewport">
<Select.Item
v-for="tz in newLocalizedTimezoneList"
:key="tz.area"
class="select-item"
:value="tz.area"
>
<Select.ItemIndicator class="select-itemindicator">
<Icon name="ph:check" />
</Select.ItemIndicator>
<Select.ItemText>
{{ tz.area.replaceAll("_", " ") }} - {{ tz.name }}
</Select.ItemText>
</Select.Item>
</Select.Viewport>
<Select.ScrollDownButton class="select-scrollbutton">
<Icon name="ph:caret-down" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Root>
</fieldset>
</div>
<div class="modal-button-wrapper">
<Dialog.Close
class="cancel"
:disabled="isLoadingUpdateUserData"
>
{{ $t("modals.cancel") }}
</Dialog.Close>
<button
class="action"
:disabled="isLoadingUpdateUserData"
@click="executeUpdateUserData"
>
<Loader v-if="isLoadingUpdateUserData" />
<span v-else>{{
$t("modals.confirm")
}}</span>
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</template>
<style scoped>
.modal p.noundo {
font-weight: bold;
color: var(--text-shade-3);
}
fieldset {
position: relative;
display: flex;
flex-flow: column;
height: min-content;
padding: 0;
gap: .25rem;
border: none;
}
.fieldsets-container {
display: grid;
grid-auto-flow: row;
grid-auto-rows: 1fr;
gap: 1rem
}
div.modal input {
background-color: var(--bg-shade-2);
}
.select-trigger,
.select-item {
display: inline-flex;
align-items: center;
justify-content: space-between;
border-radius: 4px;
padding: 12px;
line-height: 1;
height: 3em;
background-color: var(--bg-shade-3);
color: var(--text-shade-3);
width: 100%;
}
.select-item {
background: none;
}
.select-trigger:hover {
background-color: var(--bg-shade-4);
}
.select-trigger:focus {
box-shadow: 0 0 0 2px #fff;
}
:deep(.select-content) {
overflow: hidden;
background-color: var(--bg-shade-3);
margin: .25rem 0;
border-radius: 6px;
max-height: min(var(--reka-select-content-available-height), 420px);
height: fit-content;
width: var(--reka-select-trigger-width);
box-shadow:
0px 10px 38px -10px rgba(22, 23, 24, 0.4),
0px 10px 20px -15px rgba(22, 23, 24, 0.2);
}
:deep(.select-content[data-state='open']),
:deep(.date-picker-content[data-state='open']),
:deep(div:has(> .calendar-popover-content[data-state='open'])) {
z-index: 100 !important;
}
.select-item {
border-radius: 3px;
display: flex;
align-items: center;
padding: 0 35px 0 25px;
position: relative;
user-select: none;
color: var(--text-shade-2);
cursor: pointer;
}
.select-item[data-disabled] {
color: var(--text-shade-1);
pointer-events: none;
}
.select-item[data-highlighted] {
outline: none;
background-color: var(--bg-shade-4);
color: var(--text-shade-3);
}
.select-item[data-state='checked'] {
color: var(--text-shade-3);
}
.select-label {
padding: 0 25px;
font-size: 12px;
line-height: 25px;
color: var(--mauve-11);
}
.select-separator {
height: 1px;
background-color: var(--grass-6);
margin: 5px;
}
.select-itemindicator {
position: absolute;
left: 0;
width: 25px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.select-scrollbutton {
display: flex;
align-items: center;
justify-content: center;
height: 25px;
background-color: var(--bg-shade-3);
color: var(--grass-11);
cursor: default;
}
.date-field {
display: flex;
padding: 12px;
height: 3rem;
align-items: center;
border-radius: 4px;
text-align: center;
background-color: var(--bg-shade-3);
user-select: none;
color: var(--text-shade-3);
box-sizing: border-box;
}
.date-field[data-invalid] {
border: 1px solid var(--red-shade-2);
}
.date-field-literal {
padding: 0.25rem;
}
.date-field-segment {
padding: 0.25rem;
border-radius: 4px;
}
.date-field-segment:hover{
background-color: var(--bg-shade-4);
}
.date-field-segment:focus {
background-color: var(--bg-shade-4);
outline: 2px solid var(--text-shade-3)
}
.date-icon {
width: 1.5rem;
height: 1.5rem;
}
.calendar {
width: 100%;
box-sizing: border-box;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.calendar-nav-button {
all: unset;
cursor: pointer;
display: inline-flex;
justify-content: center;
align-items: center;
width: 2.5rem;
height: 2.5rem;
background-color: transparent;
cursor: pointer;
}
.calendar-nav-button:hover {
color: var(--text-shade-2);
}
.calendar-heading {
font-weight: 500;
color: 15px;
}
.calendar-wrapper {
display: flex;
flex-direction: column;
}
.calendar-grid {
margin-top: 0.25rem;
width: 100%;
user-select: none;
border-collapse: collapse;
}
.calendar-grid-row {
display: grid;
margin-bottom: 0.25rem;
grid-template-columns: repeat(7, minmax(0, 1fr));
width: 100%;
}
.calendar-head-cell {
border-radius: 0.375rem;
font-size: 0.75rem;
line-height: 1rem;
color: var(--text-shade-1);
font-weight: 400;
text-align: center;
margin-bottom: .5rem;
}
.calendar-cell {
position: relative;
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
}
.calendar-cell-trigger {
display: flex;
position: relative;
padding: 0.25rem .5rem;
justify-content: center;
align-items: center;
border-width: 1px;
border-color: transparent;
outline-style: none;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 400;
color: var(--text-shade-3);
white-space: nowrap;
background-color: transparent;
border-radius: 4px;
}
.calendar-cell-trigger:hover {
border-color: var(--text-shade-3);
}
.calendar-cell-trigger:focus {
box-shadow: 0 0 0 2px var(--text-shade-3);;
}
.calendar-cell-trigger[data-selected] {
background-color: var(--text-shade-3);
color: var(--bg-shade-3);
font-weight: bold;
}
.calendar-cell-trigger[data-selected]::before {
background-color: #FFFFFF;
}
.calendar-cell-trigger[data-outside-view] {
color: var(--text-shade-1);
}
.calendar-popover-trigger {
all: unset;
cursor: pointer;
margin-left: auto;
display: flex;
}
.calendar-popover-trigger:focus {
box-shadow: 0 0 0 2px #000000;
}
:deep(.calendar-popover-content) {
border-radius: 4px;
padding: 24px;
width: 260px;
background-color: var(--bg-shade-3-5);
color: var(--text-shade-3);
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
}
:deep(.calendar-popover-content):focus {
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px,
0 0 0 2px var(--grass-7);
}
:deep(.calendar-popover-content)[data-state='open'][data-side='top'] {
animation-name: slideDownAndFade;
}
:deep(.calendar-popover-content)[data-state='open'][data-side='right'] {
animation-name: slideLeftAndFade;
}
:deep(.calendar-popover-content)[data-state='open'][data-side='bottom'] {
animation-name: slideUpAndFade;
}
:deep(.calendar-popover-content)[data-state='open'][data-side='left'] {
animation-name: slideRightAndFade;
}
:deep(.calendar-popover-arrow) {
fill: var(--bg-shade-3-5);
}
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideRightAndFade {
from {
opacity: 0;
transform: translateX(-2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideDownAndFade {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideLeftAndFade {
from {
opacity: 0;
transform: translateX(2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

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",

View File

@@ -2,6 +2,10 @@
/* eslint-disable vue/no-v-html -- locale files still have raw html */
import { watchImmediate } from '@vueuse/core';
import { AlertDialog } from 'reka-ui/namespaced';
import {
getLocalizedTimezoneString,
regionIdToLocalizedNames
} from '@/utils/localizeConsole';
import type { ApiAccountUpdateRequest } from '~~/shared/api-types';
const { locale } = useI18n();
@@ -60,18 +64,33 @@ const { data: connections, refresh: refreshConnections } = await useApiFetch(
const dialogContainer = ref(null);
const deleteModalOpen = ref(false);
const editModalOpen = ref(false);
const selectedServerEnv = ref<'dev' | 'test' | 'prod' | undefined>(profile.value?.serverAccessLevel);
const profileEditModalOpen = ref(false);
const emailEditModalOpen = ref(false);
const selectedServerEnv = ref<'dev' | 'test' | 'prod' | undefined>(
profile.value?.serverAccessLevel
);
const timezoneString = computed(() => {
return getLocalizedTimezoneString(
locale.value,
profile.value?.region,
profile.value?.timezone
);
});
const regionStrings = computed(() => {
return regionIdToLocalizedNames(locale.value, profile.value?.region);
});
const {
execute: executeUpdateServerEnvironment,
isLoading: isLoadingUpdateServerEnv
} = useAsync({
async handler(env: ApiAccountUpdateRequest['environment']) {
async handler(env: ApiAccountUpdateRequest['serverAccessLevel']) {
await apiFetch('/api/account/update', {
method: 'PATCH',
body: {
environment: env
serverAccessLevel: env
} satisfies ApiAccountUpdateRequest
});
await refresh();
@@ -135,16 +154,6 @@ const { execute: executeUnlinkDiscord, isLoading: isLoadingUnlink } = useAsync({
}
});
function parseBirthday(iso: string): string {
const s = iso.split('-');
const y = Number(s[0]);
const m = Number(s[1]) - 1;
const d = Number(s[2]);
return new Date(Date.UTC(y, m, d)).toLocaleDateString(locale as unknown as string);
}
useHead({
title: `Account`
});
@@ -219,9 +228,7 @@ useHead({
<AlertDialog.Overlay />
<AlertDialog.Content class="modal">
<AlertDialog.Title>
{{
$t("account.settings.delete.modalTitle")
}}?
{{ $t("account.settings.delete.modalTitle") }}?
</AlertDialog.Title>
<AlertDialog.Description class="modal-caption">
<p style="white-space: pre-line">
@@ -253,19 +260,176 @@ useHead({
</AlertDialog.Root>
</div>
</div>
<AlertDialog.Root v-model:open="editModalOpen">
<div class="settings-wrapper">
<h2
id="user-settings"
class="section-header"
>
{{ $t("account.settings.settingCards.userSettings") }}
<div class="settings-wrapper">
<h2
id="user-settings"
class="section-header"
>
{{ $t("account.settings.settingCards.userSettings") }}
</h2>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.profile") }}
</h2>
<AccountInfoEditModal
v-model="profileEditModalOpen"
:profile="profile"
:dialog-container="dialogContainer"
@change="refresh"
>
<button class="edit">
<Icon
name="ph:pencil"
size="26"
/>
</button>
</AccountInfoEditModal>
<ul class="setting-list">
<li>
<p class="label">
{{ $t("account.settings.settingCards.birthDate") }}
</p>
<p class="value">
<ClientOnly>
{{ new Date(profile.birthday).toLocaleDateString(undefined, { timeZone: 'UTC'}) }}
</ClientOnly>
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.timezone") }}
</p>
<p class="value">
{{ timezoneString }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.country") }}
</p>
<p class="value">
{{ regionStrings?.country }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.region") }}
</p>
<p class="value">
{{ regionStrings?.region }}
</p>
</li>
</ul>
</div>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.serverEnv") }}
</h2>
<fieldset
:disabled="
profile.serverAccessLevel === 'prod' && profile.accessLevel < 1
"
>
<form
id="server"
class="server-selection"
>
<input
id="prod"
v-model="selectedServerEnv"
type="radio"
value="prod"
>
<label for="prod">
<Icon
name="ph:cube"
size="36"
/>
<h2>{{ $t("account.settings.settingCards.production") }}</h2>
</label>
<input
v-if="
profile.serverAccessLevel === 'test' || profile.accessLevel > 0
"
id="test"
v-model="selectedServerEnv"
type="radio"
value="test"
>
<label
v-if="
profile.serverAccessLevel === 'test' || profile.accessLevel > 0
"
for="test"
>
<Icon
name="ph:flask"
size="36"
/>
<h2>{{ $t("account.settings.settingCards.beta") }}</h2>
</label>
<input
v-if="
profile.accessLevel === 3 || profile.serverAccessLevel === 'dev'
"
id="dev"
v-model="selectedServerEnv"
type="radio"
value="dev"
>
<label
v-if="
profile.accessLevel === 3 || profile.serverAccessLevel === 'dev'
"
for="dev"
>
<Icon
name="ph:code"
size="36"
/>
<h2>Dev</h2>
</label>
</form>
</fieldset>
<button
v-if="selectedServerEnv !== profile.serverAccessLevel"
id="save-server-selection"
class="button secondary"
@click.prevent="
() => executeUpdateServerEnvironment(selectedServerEnv)
"
>
<Loader v-if="isLoadingUpdateServerEnv" />
<span v-else>Save</span>
</button>
<p
v-html="
profile.accessLevel < 1
? $t('account.settings.settingCards.upgradePrompt')
: $t('account.settings.settingCards.hasAccessPrompt')
"
/>
</div>
<h2
id="security"
class="section-header"
>
{{ $t("account.settings.settingCards.signInSecurity") }}
</h2>
<AlertDialog.Root v-model:open="emailEditModalOpen">
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.profile") }}
{{ $t("account.account") }}
</h2>
<AlertDialog.Trigger class="edit">
<Icon
name="ph:pencil"
@@ -292,160 +456,6 @@ useHead({
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
<ul class="setting-list">
<li>
<p class="label">
{{ $t("account.settings.settingCards.nickname") }}
</p>
<p class="value">
{{ profile.mii?.name }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.birthDate") }}
</p>
<p class="value">
{{ parseBirthday(profile.birthday) }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.gender") }}
</p>
<p class="value">
{{ profile.gender }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.country") }}
</p>
<p class="value">
{{ profile.country }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.timezone") }}
</p>
<p class="value">
{{ profile.timezone.replaceAll('_', ' ') }}
</p>
</li>
</ul>
</div>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.serverEnv") }}
</h2>
<fieldset
:disabled="
profile.serverAccessLevel === 'prod' && profile.accessLevel < 1
"
>
<form
id="server"
class="server-selection"
>
<input
id="prod"
v-model="selectedServerEnv"
type="radio"
value="prod"
>
<label for="prod">
<Icon
name="ph:cube"
size="36"
/>
<h2>{{ $t("account.settings.settingCards.production") }}</h2>
</label>
<input
v-if="
profile.serverAccessLevel === 'test' ||
profile.accessLevel > 0
"
id="test"
v-model="selectedServerEnv"
type="radio"
value="test"
>
<label
v-if="
profile.serverAccessLevel === 'test' ||
profile.accessLevel > 0
"
for="test"
>
<Icon
name="ph:flask"
size="36"
/>
<h2>{{ $t("account.settings.settingCards.beta") }}</h2>
</label>
<input
v-if="profile.accessLevel === 3 || profile.serverAccessLevel === 'dev'"
id="dev"
v-model="selectedServerEnv"
type="radio"
value="dev"
>
<label
v-if="profile.accessLevel === 3 || profile.serverAccessLevel === 'dev'"
for="dev"
>
<Icon
name="ph:code"
size="36"
/>
<h2>Dev</h2>
</label>
</form>
</fieldset>
<button
v-if="
selectedServerEnv !== profile.serverAccessLevel
"
id="save-server-selection"
class="button secondary"
@click.prevent="
() => executeUpdateServerEnvironment(selectedServerEnv)
"
>
<Loader v-if="isLoadingUpdateServerEnv" />
<span v-else>Save</span>
</button>
<p
v-html="
profile.accessLevel < 1
? $t('account.settings.settingCards.upgradePrompt')
: $t('account.settings.settingCards.hasAccessPrompt')
"
/>
</div>
<h2
id="security"
class="section-header"
>
{{ $t("account.settings.settingCards.signInSecurity") }}
</h2>
<div class="setting-card">
<h2 class="header">
{{ $t("account.account") }}
</h2>
<AlertDialog.Trigger class="edit">
<Icon
name="ph:pencil"
size="26"
/>
</AlertDialog.Trigger>
<ul class="setting-list">
<li>
<p class="label">
@@ -466,83 +476,88 @@ useHead({
</ul>
<p>{{ $t("account.settings.settingCards.passwordResetNotice") }}</p>
</div>
</AlertDialog.Root>
<div class="setting-card sign-in-history">
<h2 class="header">
{{ $t("account.settings.settingCards.signInHistory") }}
</h2>
<p>{{ $t("account.settings.settingCards.no_signins_notice") }}</p>
</div>
<h2
id="other"
class="section-header"
>
{{ $t("account.settings.settingCards.otherSettings") }}
<div class="setting-card sign-in-history">
<h2 class="header">
{{ $t("account.settings.settingCards.signInHistory") }}
</h2>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.discord") }}
</h2>
<p>{{ $t("account.settings.settingCards.no_signins_notice") }}</p>
</div>
<p
v-if="profile.discordId"
class="discord-profile"
>
{{ $t("account.settings.settingCards.connectedToDiscord") }}
<img
:style="{ height: '25px', width: '25px', borderRadius: '100px' }"
:src="connections?.discord?.avatarUrl ?? '#'"
>@{{ connections?.discord?.username }}.
</p>
<h2
id="other"
class="section-header"
>
{{ $t("account.settings.settingCards.otherSettings") }}
</h2>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.discord") }}
</h2>
<button
v-if="profile.discordId"
id="remove-discord-connection"
class="button secondary"
@click="executeUnlinkDiscord"
<p
v-if="profile.discordId"
class="discord-profile"
>
{{ $t("account.settings.settingCards.connectedToDiscord") }}
<img
:style="{ height: '25px', width: '25px', borderRadius: '100px' }"
:src="connections?.discord?.avatarUrl ?? '#'"
>@{{ connections?.discord?.username }}.
</p>
<button
v-if="profile.discordId"
id="remove-discord-connection"
class="button secondary"
@click="executeUnlinkDiscord"
>
<Loader v-if="isLoadingUnlink" />
<span v-else>{{
$t("account.settings.settingCards.removeDiscord")
}}</span>
</button>
<p v-else>
{{ $t("account.settings.settingCards.noDiscordLinked") }}
<NuxtLink
:style="{ cursor: 'pointer' }"
@click="executeLinkDiscord"
>
<Loader v-if="isLoadingUnlink" />
<Loader v-if="isLoadingLink" />
<span v-else>{{
$t("account.settings.settingCards.removeDiscord")
$t("account.settings.settingCards.linkDiscord")
}}</span>
</button>
<p v-else>
{{ $t("account.settings.settingCards.noDiscordLinked") }}
<NuxtLink
:style="{ cursor: 'pointer' }"
@click="executeLinkDiscord"
>
<Loader v-if="isLoadingLink" />
<span v-else>{{
$t("account.settings.settingCards.linkDiscord")
}}</span>
</NuxtLink>
</p>
</div>
</NuxtLink>
</p>
</div>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.newsletter") }}
</h2>
<p>{{ $t("account.settings.settingCards.no_newsletter_notice") }}</p>
<!--
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.newsletter") }}
</h2>
<p>{{ $t("account.settings.settingCards.no_newsletter_notice") }}</p>
<!--
<form id="other">
<input type="checkbox" id="marketing" name="marketing" {{#if account.flags.marketing}}checked{{/if}}>
<label for="marketing">{{ locale.account.settings.settingCards.newsletterPrompt }}</label>
</form>
-->
</div>
</div>
</AlertDialog.Root>
</div>
<div
id="delete-account"
:class="{
'modal-wrapper': true,
hidden: !(deleteModalOpen || editModalOpen),
hidden: !(
deleteModalOpen ||
profileEditModalOpen ||
emailEditModalOpen
),
}"
>
<div ref="dialogContainer" />
<div class="modal-binder">
<div ref="dialogContainer" />
</div>
</div>
</div>
</template>
@@ -762,6 +777,27 @@ useHead({
margin-bottom: 4px;
}
.modal-wrapper {
padding: 1.5rem;
box-sizing: border-box;
}
.modal-binder {
height: 100%;
flex-grow: 9;
width: 100%;
overflow-y: scroll;
overflow-x: hidden;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
.modal-binder > div {
max-height: 100%;
height: fit-content;
}
fieldset {
position: relative;
height: min-content;

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,185 @@
import regions from '@/assets/json/regions.json';
import timezoneData from '@/assets/json/timezones.json';
type Timezone = {
area: string;
language: string;
name: string;
utc_offset: string;
order: string;
};
type TimezoneLanguageCollection = Record<string, Array<Timezone>>;
type TimezoneCollection = Record<string, TimezoneLanguageCollection>;
const timezones = timezoneData as TimezoneCollection;
type languageName = 'japanese' | 'french' | 'german' | 'italian' | 'spanish' | 'korean' | 'dutch' | 'portuguese' | 'russian' | 'chinese_traditional' | 'chinese_simple' | 'english';
type languageCode = 'ja' | 'fr' | 'de' | 'it' | 'es' | 'ko' | 'nl' | 'pt' | 'ru' | 'zh-Hant' | 'zh-Hans' | 'en';
export function webLocaleToConsoleLocale(lo: string): languageName {
const convObj: Record<languageCode, languageName> = {
'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',
'en': 'english'
};
const l = lo.split('-')[0]!;
const correspondence = convObj[l as languageCode];
return correspondence || convObj[lo as languageCode] || convObj.en;
}
export function webLocaleToTimezoneLocale(lo: string): languageCode {
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 regionIdToCountryId(region: number) {
return (region >>> 24) & 0xFF;
}
export function countryIdToUndefinedRegionId(region: number) {
return region * (2 ** 24);
}
export function regionIdToCountryObject(region: number) {
const countryId = regionIdToCountryId(region);
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);
if (!country) {
return;
}
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;
}