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:
@@ -20,6 +20,7 @@ export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||
export const iconInfo: AppIcon = 'mdi:information-outline';
|
||||
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
||||
export const iconKey: AppIcon = 'mdi:key-outline';
|
||||
export const iconLife: AppIcon = 'mdi:post-outline';
|
||||
export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
|
||||
export const iconLogin: AppIcon = 'mdi:login';
|
||||
|
||||
@@ -11,8 +11,10 @@ import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import RegisterView from '../views/RegisterView.vue';
|
||||
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||
import { api, getAuthToken, setAuthToken } from '../services/api';
|
||||
|
||||
@@ -45,6 +47,8 @@ export const router = createRouter({
|
||||
{ path: '/life', component: LifeView },
|
||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/forgot-password', component: ForgotPasswordView },
|
||||
{ path: '/reset-password', component: ResetPasswordView },
|
||||
{ path: '/register', component: RegisterView },
|
||||
{ path: '/verify-email', component: VerifyEmailView }
|
||||
],
|
||||
|
||||
@@ -277,6 +277,7 @@ export interface AuthUser {
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterPayload extends LoginPayload {
|
||||
@@ -418,26 +419,39 @@ export function buildQuery(params: Record<string, string | number | undefined>):
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
function authStorage(type: 'local' | 'session'): Storage | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localStorage.getItem(authTokenKey);
|
||||
return type === 'local' ? window.localStorage : window.sessionStorage;
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
export function getAuthToken(): string | null {
|
||||
const sessionToken = authStorage('session')?.getItem(authTokenKey);
|
||||
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
|
||||
const local = authStorage('local');
|
||||
const session = authStorage('session');
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem(authTokenKey, token);
|
||||
if (options.persistent === false) {
|
||||
session?.setItem(authTokenKey, token);
|
||||
local?.removeItem(authTokenKey);
|
||||
} else {
|
||||
local?.setItem(authTokenKey, token);
|
||||
session?.removeItem(authTokenKey);
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(authTokenKey);
|
||||
local?.removeItem(authTokenKey);
|
||||
session?.removeItem(authTokenKey);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(authChangeEvent));
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(authChangeEvent));
|
||||
}
|
||||
}
|
||||
|
||||
export function onAuthTokenChange(callback: () => void): () => void {
|
||||
@@ -548,6 +562,10 @@ export const api = {
|
||||
verifyEmail: (token: string) =>
|
||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||
login: (payload: LoginPayload) => sendJson<AuthResponse>('/api/auth/login', 'POST', payload),
|
||||
requestPasswordReset: (payload: { email: string }) =>
|
||||
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
|
||||
resetPassword: (payload: { token: string; password: string }) =>
|
||||
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
options: () => getJson<Options>('/api/options'),
|
||||
|
||||
@@ -3784,6 +3784,27 @@ button:disabled,
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auth-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.auth-options__remember {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-options a {
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
|
||||
59
frontend/src/views/ForgotPasswordView.vue
Normal file
59
frontend/src/views/ForgotPasswordView.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
98
frontend/src/views/ResetPasswordView.vue
Normal file
98
frontend/src/views/ResetPasswordView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user