feat(auth): add password reset and remember me options

Add password reset request and reset endpoints with email verification
Add "Remember me" option to login for persistent sessions
Create frontend views for forgot and reset password flows
This commit is contained in:
2026-05-02 22:13:10 +08:00
parent 97f06794a8
commit 4a42756e2e
12 changed files with 456 additions and 26 deletions

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconMail } from '../icons';
import { api } from '../services/api';
const { t } = useI18n();
const email = ref('');
const busy = ref(false);
const message = ref('');
const errorMessage = ref('');
async function submitResetRequest() {
busy.value = true;
message.value = '';
errorMessage.value = '';
try {
const response = await api.requestPasswordReset({ email: email.value });
message.value = response.message;
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.requestResetFailed');
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<PageHeader :title="t('auth.requestResetTitle')" :subtitle="t('auth.requestResetSubtitle')">
<template #kicker>{{ t('auth.accountAccess') }}</template>
</PageHeader>
<form class="auth-form" @submit.prevent="submitResetRequest">
<div class="field">
<label for="forgot-password-email">{{ t('auth.email') }}</label>
<input id="forgot-password-email" v-model="email" autocomplete="email" required type="email" />
</div>
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
<Icon :icon="iconMail" class="ui-icon" aria-hidden="true" />
{{ busy ? t('auth.sending') : t('auth.sendResetLink') }}
</button>
</form>
<p class="auth-switch">
<RouterLink to="/login">{{ t('auth.goLogin') }}</RouterLink>
</p>
</div>
</section>
</template>

View File

@@ -13,6 +13,7 @@ const router = useRouter();
const { t } = useI18n();
const email = ref('');
const password = ref('');
const rememberMe = ref(false);
const busy = ref(false);
const errorMessage = ref('');
@@ -23,9 +24,10 @@ async function submitLogin() {
try {
const response = await api.login({
email: email.value,
password: password.value
password: password.value,
rememberMe: rememberMe.value
});
setAuthToken(response.token);
setAuthToken(response.token, { persistent: rememberMe.value });
const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
@@ -44,7 +46,7 @@ async function submitLogin() {
<section class="auth-page">
<div class="auth-panel">
<PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
<template #kicker>Trainer Pass</template>
<template #kicker>{{ t('auth.accountAccess') }}</template>
</PageHeader>
<form class="auth-form" @submit.prevent="submitLogin">
@@ -58,6 +60,14 @@ async function submitLogin() {
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
</div>
<div class="auth-options">
<label class="check-row auth-options__remember">
<input v-model="rememberMe" type="checkbox" />
{{ t('auth.rememberMe') }}
</label>
<RouterLink to="/forgot-password">{{ t('auth.forgotPassword') }}</RouterLink>
</div>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconKey, iconLogin } from '../icons';
import { api } from '../services/api';
const { t } = useI18n();
const route = useRoute();
const password = ref('');
const confirmPassword = ref('');
const busy = ref(false);
const message = ref('');
const errorMessage = ref('');
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''));
async function submitPasswordReset() {
message.value = '';
errorMessage.value = '';
if (!token.value) {
errorMessage.value = t('auth.invalidPasswordReset');
return;
}
if (password.value !== confirmPassword.value) {
errorMessage.value = t('auth.passwordMismatch');
return;
}
busy.value = true;
try {
const response = await api.resetPassword({ token: token.value, password: password.value });
message.value = response.message;
password.value = '';
confirmPassword.value = '';
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.resetFailed');
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<PageHeader :title="t('auth.resetTitle')" :subtitle="t('auth.resetSubtitle')">
<template #kicker>{{ t('auth.accountAccess') }}</template>
</PageHeader>
<form v-if="!message" class="auth-form" @submit.prevent="submitPasswordReset">
<div class="field">
<label for="reset-password">{{ t('auth.newPassword') }}</label>
<input
id="reset-password"
v-model="password"
autocomplete="new-password"
minlength="8"
required
type="password"
/>
</div>
<div class="field">
<label for="reset-password-confirm">{{ t('auth.confirmPassword') }}</label>
<input
id="reset-password-confirm"
v-model="confirmPassword"
autocomplete="new-password"
minlength="8"
required
type="password"
/>
</div>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
<Icon :icon="iconKey" class="ui-icon" aria-hidden="true" />
{{ busy ? t('auth.resetting') : t('auth.resetPassword') }}
</button>
</form>
<StatusMessage v-else variant="success">{{ message }}</StatusMessage>
<RouterLink v-if="message" class="ui-button ui-button--ghost" to="/login">
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
{{ t('auth.goLogin') }}
</RouterLink>
</div>
</section>
</template>