Files
VRCX/src/views/Login/Login.vue
2026-03-13 20:05:02 +09:00

381 lines
15 KiB
Vue

<template>
<div class="x-login-container">
<div class="m-1.5" style="position: absolute; top: 0; left: 0">
<LoginSettingsDialog />
<TooltipWrapper v-if="!noUpdater" side="top" :content="t('view.login.updater')">
<Button class="rounded-full mr-2 text-xs" size="icon-sm" variant="ghost" @click="showVRCXUpdateDialog"
><ArrowBigDownDash
/></Button>
</TooltipWrapper>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<TooltipWrapper side="top" :content="t('view.login.language')">
<Button class="rounded-full text-xs" size="icon-sm" variant="ghost">
<Languages />
</Button>
</TooltipWrapper>
</DropdownMenuTrigger>
<DropdownMenuContent class="max-h-80 overflow-y-auto text-xs">
<DropdownMenuCheckboxItem
v-for="language in languageCodes"
:key="language"
:model-value="appLanguage === language"
@select="changeAppLanguage(language)">
{{ getLanguageName(language) }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="x-login">
<div class="x-login-form-container">
<div>
<h2 class="m-0" style="font-weight: bold; text-align: center">{{ t('view.login.login') }}</h2>
<form id="login-form" @submit.prevent="onSubmit">
<FieldGroup class="gap-3">
<VeeField v-slot="{ field, errors }" name="username">
<Field :data-invalid="!!errors.length">
<FieldLabel for="login-form-username">
{{ t('view.login.field.username') }}
</FieldLabel>
<FieldContent>
<InputGroupField
id="login-form-username"
:model-value="field.value"
autocomplete="off"
name="username"
:placeholder="t('view.login.field.username')"
:aria-invalid="!!errors.length"
clearable
@update:modelValue="field.onChange"
@blur="field.onBlur" />
<FieldError v-if="errors.length" :errors="errors" />
</FieldContent>
</Field>
</VeeField>
<VeeField v-slot="{ field, errors }" name="password">
<Field :data-invalid="!!errors.length">
<FieldLabel for="login-form-password">
{{ t('view.login.field.password') }}
</FieldLabel>
<FieldContent>
<InputGroupField
id="login-form-password"
:model-value="field.value"
type="password"
autocomplete="off"
name="password"
:placeholder="t('view.login.field.password')"
:aria-invalid="!!errors.length"
clearable
show-password
@update:modelValue="field.onChange"
@blur="field.onBlur" />
<FieldError v-if="errors.length" :errors="errors" />
</FieldContent>
</Field>
</VeeField>
</FieldGroup>
<label class="inline-flex items-center gap-2 mr-2 mt-3 text-sm">
<Checkbox v-model="loginForm.saveCredentials" />
<span>{{ t('view.login.field.saveCredentials') }}</span>
</label>
<Field class="mt-4">
<Button type="submit" size="lg" style="width: 100%">{{ t('view.login.login') }}</Button>
</Field>
</form>
<Button
variant="Secondary"
size="lg"
style="width: 100%"
@click="openExternalLink('https://vrchat.com/register')"
>{{ t('view.login.register') }}</Button
>
</div>
<hr v-if="Object.keys(savedCredentials).length !== 0" class="x-vertical-divider" />
<div v-if="Object.keys(savedCredentials).length !== 0">
<h2 class="m-0" style="font-weight: bold; text-align: center">
{{ t('view.login.savedAccounts') }}
</h2>
<div class="x-scroll-wrapper mt-2">
<div class="x-saved-account-list">
<Item
v-for="user in savedCredentials"
:key="user.user.id"
class="cursor-pointer hover:bg-muted p-2 border-0"
@click="clickSavedLogin(user)">
<ItemMedia variant="image">
<Avatar class="rounded-full">
<AvatarImage :src="userImage(user.user)" />
<AvatarFallback>
<User class="size-5 text-muted-foreground" />
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent class="min-w-0">
<ItemTitle class="truncate max-w-full">{{ user.user.displayName }}</ItemTitle>
<ItemDescription class="truncate text-xs!">
{{ user.user.username }}
</ItemDescription>
<ItemDescription v-if="user.loginParams.endpoint" class="truncate text-xs!">
{{ user.loginParams.endpoint }}
</ItemDescription>
</ItemContent>
<ItemActions @click.stop>
<Button
size="icon-sm"
variant="ghost"
class="cursor-pointer rounded-full"
@click="clickDeleteSavedLogin(user.user.id)"
><Trash2 class="text-sm"
/></Button>
</ItemActions>
</Item>
</div>
</div>
</div>
</div>
<div class="x-legal-notice-container">
<div class="text-center text-xs">
<p>
<a class="cursor-pointer" @click="openExternalLink('https://vrchat.com/home/password')">{{
t('view.login.forgotPassword')
}}</a>
</p>
<p>
&copy; 2019-2026
<a class="cursor-pointer" @click="openExternalLink('https://github.com/pypy-vrc')">pypy</a>
&amp;
<a class="cursor-pointer" @click="openExternalLink('https://github.com/Natsumi-sama')"
>Natsumi</a
>
&amp;
<a class="cursor-pointer" @click="openExternalLink('https://github.com/Map1en')">Map1en</a>
</p>
<p>{{ t('view.settings.general.legal_notice.info') }}</p>
<p>{{ t('view.settings.general.legal_notice.disclaimer1') }}</p>
<p>{{ t('view.settings.general.legal_notice.disclaimer2') }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Field, FieldContent, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';
import { ArrowBigDownDash, Languages, Trash2, User } from 'lucide-vue-next';
import { Field as VeeField, useForm } from 'vee-validate';
import { useRoute, useRouter } from 'vue-router';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { InputGroupField } from '@/components/ui/input-group';
import { storeToRefs } from 'pinia';
import { toTypedSchema } from '@vee-validate/zod';
import { useI18n } from 'vue-i18n';
import { z } from 'zod';
import { useAppearanceSettingsStore, useAuthStore, useVRCXUpdaterStore } from '../../stores';
import { getLanguageName, languageCodes } from '../../localization';
import { openExternalLink } from '../../shared/utils';
import { useUserDisplay } from '../../composables/useUserDisplay';
import { watchState } from '../../services/watchState';
import LoginSettingsDialog from './Dialog/LoginSettingsDialog.vue';
const { userImage } = useUserDisplay();
const { showVRCXUpdateDialog } = useVRCXUpdaterStore();
const router = useRouter();
const route = useRoute();
const { loginForm } = storeToRefs(useAuthStore());
const { relogin, deleteSavedLogin, login, getAllSavedCredentials } = useAuthStore();
const { noUpdater } = storeToRefs(useVRCXUpdaterStore());
const appearanceSettingsStore = useAppearanceSettingsStore();
const { appLanguage } = storeToRefs(appearanceSettingsStore);
const { changeAppLanguage } = appearanceSettingsStore;
const { t } = useI18n();
const savedCredentials = ref({});
const requiredMessage = 'Required';
const formSchema = toTypedSchema(
z.object({
username: z.string().min(1, requiredMessage),
password: z.string().min(1, requiredMessage)
})
);
const { handleSubmit, resetForm, values } = useForm({
validationSchema: formSchema,
initialValues: {
username: loginForm.value.username,
password: loginForm.value.password
}
});
/**
*
* @param userId
*/
async function clickDeleteSavedLogin(userId) {
await deleteSavedLogin(userId);
await updateSavedCredentials();
}
/**
*
* @param user
*/
async function clickSavedLogin(user) {
await relogin(user);
await updateSavedCredentials();
}
const onSubmit = handleSubmit(async (formValues) => {
loginForm.value.username = formValues.username ?? '';
loginForm.value.password = formValues.password ?? '';
await login();
await updateSavedCredentials();
});
/**
*
*/
async function updateSavedCredentials() {
if (watchState.isLoggedIn) {
return;
}
savedCredentials.value = await getAllSavedCredentials();
}
/**
*
*/
function postLoginRedirect() {
const redirect = route.query.redirect;
if (typeof redirect === 'string' && redirect.startsWith('/') && redirect !== '/login') {
return redirect;
}
return '/feed';
}
watch(
() => watchState.isLoggedIn,
(isLoggedIn) => {
if (isLoggedIn) {
router.replace(postLoginRedirect());
}
}
);
watch(
() => loginForm.value.loading,
(loading) => {
if (!loading) {
updateSavedCredentials();
}
}
);
onBeforeMount(async () => {
updateSavedCredentials();
});
onBeforeUnmount(() => {
resetForm({
values: {
username: '',
password: ''
}
});
loginForm.value.username = '';
loginForm.value.password = '';
loginForm.value.endpoint = '';
loginForm.value.websocket = '';
savedCredentials.value = {};
});
watch(
values,
(formValues) => {
loginForm.value.username = formValues.username ?? '';
loginForm.value.password = formValues.password ?? '';
},
{ deep: true }
);
</script>
<style scoped>
.x-login-container {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.x-login {
display: grid;
grid-template-rows: repeat(2, auto);
align-items: center;
max-width: clamp(600px, 60svw, 800px);
}
.x-login-form-container {
display: grid;
gap: 8px;
height: 380px;
}
.x-login-form-container:has(> div:nth-child(3)) {
grid-template-columns: 1fr 1px 1fr;
}
.x-login-form-container > div {
display: flex;
flex-direction: column;
min-height: 0;
padding: 16px;
overflow-y: auto;
}
.x-scroll-wrapper {
width: 100%;
height: 100%;
overflow-y: auto;
}
hr.x-vertical-divider {
height: 100%;
width: 100%;
margin: 0;
border: 0;
}
.x-saved-account-list {
display: grid;
}
.x-saved-account-list > div {
width: 100%;
}
.x-legal-notice-container {
margin-top: 8px;
}
</style>