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>