mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-08-03 19:17:07 -04:00
210 lines
6.9 KiB
Vue
210 lines
6.9 KiB
Vue
<script setup lang="ts">
|
|
import { importSPKI, jwtVerify } from 'jose';
|
|
import type { FetchOptions } from 'ofetch';
|
|
|
|
import { getUrlForLocale } from '~/src/domain.ts';
|
|
import { socialProviders } from '~/src/socialProviders.ts';
|
|
import type { User } from '~/src/user.ts';
|
|
|
|
const { $setToken: setToken } = useNuxtApp();
|
|
const runtimeConfig = useRuntimeConfig();
|
|
const config = useConfig();
|
|
|
|
const token = ref<string | null>(null);
|
|
const usernameOrEmail = ref('');
|
|
const code = ref('');
|
|
|
|
const error = ref('');
|
|
|
|
const saving = ref(false);
|
|
|
|
const captchaToken = ref<string | null>(null);
|
|
|
|
const payload = ref<User | null>(null);
|
|
|
|
const codeInput = useTemplateRef('codeInput');
|
|
watchEffect(async () => {
|
|
if (!token.value) {
|
|
return null;
|
|
}
|
|
|
|
await setToken(token.value);
|
|
|
|
const importedPublicKey = await importSPKI(runtimeConfig.public.publicKey, 'RS256');
|
|
const baseUrl = getUrlForLocale(config.locale);
|
|
|
|
const result = await jwtVerify<User>(token.value, importedPublicKey, {
|
|
algorithms: ['RS256'],
|
|
audience: baseUrl,
|
|
issuer: baseUrl,
|
|
});
|
|
payload.value = result.payload;
|
|
|
|
await nextTick();
|
|
codeInput.value?.focus();
|
|
});
|
|
|
|
const canInit = computed(() => {
|
|
return usernameOrEmail.value && captchaToken.value;
|
|
});
|
|
|
|
const login = async () => {
|
|
if (saving.value) {
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
try {
|
|
await post('/api/user/init', {
|
|
usernameOrEmail: usernameOrEmail.value,
|
|
captchaToken: captchaToken.value,
|
|
});
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
};
|
|
const validate = async () => {
|
|
if (saving.value) {
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
try {
|
|
await post('/api/user/validate', {
|
|
code: code.value,
|
|
}, {
|
|
headers: {
|
|
authorization: `Bearer ${token.value}`,
|
|
},
|
|
});
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
};
|
|
|
|
const dialogue = useDialogue();
|
|
const post = async (
|
|
url: string,
|
|
data: Record<string, unknown>,
|
|
options: Omit<FetchOptions, 'method' | 'data' | 'timeout'> = {},
|
|
) => {
|
|
error.value = '';
|
|
|
|
const response = await dialogue.postWithAlertOnError<{ token: string } | { error: string }>(url, data, options);
|
|
|
|
usernameOrEmail.value = '';
|
|
code.value = '';
|
|
|
|
if ('error' in response) {
|
|
error.value = response.error;
|
|
return;
|
|
}
|
|
|
|
token.value = response.token;
|
|
};
|
|
|
|
const getEmail = (payload: User): string => {
|
|
return payload.email || payload.emailObfuscated || '';
|
|
};
|
|
const addBrackets = (str: string) => {
|
|
return str ? `(${str})` : '';
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<section>
|
|
<Alert type="danger" :message="error" />
|
|
|
|
<div class="card shadow">
|
|
<div class="card-body">
|
|
<div v-if="token === null">
|
|
<p v-if="$te('user.login.help')">
|
|
<Icon v="info-circle" />
|
|
<T>user.login.help</T>
|
|
</p>
|
|
<div class="row flex-row-reverse">
|
|
<div class="col-12 col-md-4">
|
|
<div class="btn-group-vertical w-100 mb-3">
|
|
<SocialLogin
|
|
v-for="(providerOptions, provider) in socialProviders"
|
|
:key="provider"
|
|
:provider="provider"
|
|
:options="providerOptions"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-8">
|
|
<form :inert="saving" class="mb-4 mb-md-0" @submit.prevent="login">
|
|
<input
|
|
v-model="usernameOrEmail"
|
|
type="text"
|
|
class="form-control mb-3"
|
|
:placeholder="$t('user.login.placeholder')"
|
|
autofocus
|
|
required
|
|
>
|
|
<p class="small text-muted mb-1">
|
|
<Icon v="info-circle" />
|
|
<T>captcha.reason</T>
|
|
</p>
|
|
<Captcha v-model="captchaToken" />
|
|
<button class="btn btn-primary mt-3 d-none d-md-block" :disabled="!canInit">
|
|
<Icon v="sign-in" />
|
|
<T>user.login.action</T>
|
|
</button>
|
|
<button class="btn btn-primary mt-3 d-block-force d-md-none w-100" :disabled="!canInit">
|
|
<Icon v="sign-in" />
|
|
<T>user.login.action</T>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="payload && !payload.code">
|
|
<div class="alert alert-success">
|
|
<p class="mb-0">
|
|
<Icon v="envelope-open-text" />
|
|
<T :params="{ email: addBrackets(getEmail(payload)) }">user.login.emailSent</T>
|
|
</p>
|
|
</div>
|
|
|
|
<form :inert="saving" @submit.prevent="validate">
|
|
<div class="input-group mb-3">
|
|
<input
|
|
ref="codeInput"
|
|
v-model="code"
|
|
type="text"
|
|
class="form-control text-center"
|
|
placeholder="000000"
|
|
autofocus
|
|
required
|
|
minlength="0"
|
|
maxlength="6"
|
|
inputmode="numeric"
|
|
pattern="[0-9]{6}"
|
|
autocomplete="one-time-code"
|
|
>
|
|
<button class="btn btn-primary">
|
|
<Icon v="key" />
|
|
<T>user.code.action</T>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<div class="card-footer small">
|
|
<p>
|
|
<Icon v="info-circle" />
|
|
<T>user.login.why</T>
|
|
</p>
|
|
<p>
|
|
<Icon v="gavel" />
|
|
<T>terms.consent</T>
|
|
</p>
|
|
<p class="mb-0">
|
|
<Icon v="lock" />
|
|
<T>user.login.passwordless</T>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|