Skip to content

Commit aa00e89

Browse files
authored
Merge pull request #144 from reqcore-inc/feat/forgot-password
feat: implement forgot password and reset password functionality
2 parents 753b37e + 3f6a56b commit aa00e89

7 files changed

Lines changed: 408 additions & 5 deletions

File tree

app/pages/auth/forgot-password.vue

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<script setup lang="ts">
2+
definePageMeta({
3+
layout: "auth",
4+
middleware: ["guest"],
5+
});
6+
7+
useSeoMeta({
8+
title: "Forgot Password — Reqcore",
9+
description: "Reset your Reqcore account password",
10+
robots: "noindex, nofollow",
11+
});
12+
13+
const email = ref("");
14+
const error = ref("");
15+
const success = ref(false);
16+
const isLoading = ref(false);
17+
const localePath = useLocalePath();
18+
const { track } = useTrack();
19+
20+
onMounted(() => track("forgot_password_page_viewed"));
21+
22+
async function handleRequestReset() {
23+
error.value = "";
24+
25+
if (!email.value) {
26+
error.value = "Email is required.";
27+
return;
28+
}
29+
30+
isLoading.value = true;
31+
32+
try {
33+
const result = await authClient.requestPasswordReset({
34+
email: email.value,
35+
redirectTo: `${window.location.origin}${localePath("/auth/reset-password")}`,
36+
});
37+
38+
if (result.error) {
39+
error.value =
40+
result.error.message ?? "Failed to send reset email. Please try again.";
41+
isLoading.value = false;
42+
return;
43+
}
44+
} catch (e: unknown) {
45+
error.value =
46+
e instanceof Error ? e.message : "Failed to send reset email. Please try again.";
47+
isLoading.value = false;
48+
return;
49+
}
50+
51+
track("forgot_password_submitted");
52+
53+
// Always show success to prevent email enumeration
54+
success.value = true;
55+
isLoading.value = false;
56+
}
57+
</script>
58+
59+
<template>
60+
<div class="flex flex-col gap-4">
61+
<h2
62+
class="text-xl font-semibold text-center text-surface-900 dark:text-surface-100 mb-2"
63+
>
64+
Reset your password
65+
</h2>
66+
67+
<template v-if="success">
68+
<div
69+
class="rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950 p-3 text-sm text-green-700 dark:text-green-400"
70+
>
71+
If an account with that email exists, we've sent a password reset link.
72+
Please check your inbox and spam folder.
73+
</div>
74+
75+
<p class="text-center text-sm text-surface-500 dark:text-surface-400 mt-2">
76+
<NuxtLink
77+
:to="$localePath('/auth/sign-in')"
78+
class="text-brand-600 dark:text-brand-400 hover:underline"
79+
>
80+
Back to sign in
81+
</NuxtLink>
82+
</p>
83+
</template>
84+
85+
<template v-else>
86+
<p class="text-sm text-surface-500 dark:text-surface-400 text-center">
87+
Enter your email address and we'll send you a link to reset your password.
88+
</p>
89+
90+
<div
91+
v-if="error"
92+
class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
93+
>
94+
{{ error }}
95+
</div>
96+
97+
<form class="flex flex-col gap-4" @submit.prevent="handleRequestReset">
98+
<label
99+
class="flex flex-col gap-1 text-sm font-medium text-surface-700 dark:text-surface-300"
100+
>
101+
<span>Email</span>
102+
<input
103+
v-model="email"
104+
type="email"
105+
autocomplete="email"
106+
required
107+
class="px-3 py-2 border border-surface-300 dark:border-surface-700 rounded-md text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 outline-none transition-colors focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15"
108+
/>
109+
</label>
110+
111+
<button
112+
type="submit"
113+
:disabled="isLoading"
114+
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
115+
>
116+
{{ isLoading ? "Sending…" : "Send reset link" }}
117+
</button>
118+
</form>
119+
120+
<p class="text-center text-sm text-surface-500 dark:text-surface-400">
121+
Remember your password?
122+
<NuxtLink
123+
:to="$localePath('/auth/sign-in')"
124+
class="text-brand-600 dark:text-brand-400 hover:underline"
125+
>
126+
Sign in
127+
</NuxtLink>
128+
</p>
129+
</template>
130+
</div>
131+
</template>

app/pages/auth/reset-password.vue

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<script setup lang="ts">
2+
definePageMeta({
3+
layout: "auth",
4+
middleware: ["guest"],
5+
});
6+
7+
useSeoMeta({
8+
title: "Reset Password — Reqcore",
9+
description: "Set a new password for your Reqcore account",
10+
robots: "noindex, nofollow",
11+
});
12+
13+
const route = useRoute();
14+
const newPassword = ref("");
15+
const confirmPassword = ref("");
16+
const error = ref("");
17+
const success = ref(false);
18+
const isLoading = ref(false);
19+
const localePath = useLocalePath();
20+
const { track } = useTrack();
21+
22+
const token = computed(() => route.query.token as string | undefined);
23+
const tokenError = computed(() => route.query.error as string | undefined);
24+
25+
onMounted(() => track("reset_password_page_viewed"));
26+
27+
async function handleResetPassword() {
28+
error.value = "";
29+
30+
if (!token.value) {
31+
error.value = "Invalid or missing reset token. Please request a new password reset link.";
32+
return;
33+
}
34+
35+
if (!newPassword.value) {
36+
error.value = "Password is required.";
37+
return;
38+
}
39+
40+
if (newPassword.value.length < 8) {
41+
error.value = "Password must be at least 8 characters.";
42+
return;
43+
}
44+
45+
if (newPassword.value !== confirmPassword.value) {
46+
error.value = "Passwords do not match.";
47+
return;
48+
}
49+
50+
isLoading.value = true;
51+
52+
try {
53+
const result = await authClient.resetPassword({
54+
newPassword: newPassword.value,
55+
token: token.value,
56+
});
57+
58+
if (result.error) {
59+
error.value =
60+
result.error.message ?? "Failed to reset password. The link may have expired.";
61+
isLoading.value = false;
62+
return;
63+
}
64+
} catch (e: unknown) {
65+
error.value =
66+
e instanceof Error ? e.message : "Failed to reset password. Please try again.";
67+
isLoading.value = false;
68+
return;
69+
}
70+
71+
track("reset_password_completed");
72+
success.value = true;
73+
isLoading.value = false;
74+
}
75+
</script>
76+
77+
<template>
78+
<div class="flex flex-col gap-4">
79+
<h2
80+
class="text-xl font-semibold text-center text-surface-900 dark:text-surface-100 mb-2"
81+
>
82+
Set new password
83+
</h2>
84+
85+
<template v-if="success">
86+
<div
87+
class="rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950 p-3 text-sm text-green-700 dark:text-green-400"
88+
>
89+
Your password has been reset successfully.
90+
</div>
91+
92+
<NuxtLink
93+
:to="$localePath('/auth/sign-in')"
94+
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 transition-colors text-center block"
95+
>
96+
Sign in with new password
97+
</NuxtLink>
98+
</template>
99+
100+
<template v-else-if="tokenError || !token">
101+
<div
102+
class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
103+
>
104+
{{ tokenError === 'INVALID_TOKEN'
105+
? "This password reset link is invalid or has expired."
106+
: "Invalid password reset link. Please request a new one." }}
107+
</div>
108+
109+
<NuxtLink
110+
:to="$localePath('/auth/forgot-password')"
111+
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 transition-colors text-center block"
112+
>
113+
Request new reset link
114+
</NuxtLink>
115+
</template>
116+
117+
<template v-else>
118+
<div
119+
v-if="error"
120+
class="rounded-md border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400"
121+
>
122+
{{ error }}
123+
</div>
124+
125+
<form class="flex flex-col gap-4" @submit.prevent="handleResetPassword">
126+
<label
127+
class="flex flex-col gap-1 text-sm font-medium text-surface-700 dark:text-surface-300"
128+
>
129+
<span>New password</span>
130+
<input
131+
v-model="newPassword"
132+
type="password"
133+
autocomplete="new-password"
134+
required
135+
minlength="8"
136+
class="px-3 py-2 border border-surface-300 dark:border-surface-700 rounded-md text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 outline-none transition-colors focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15"
137+
/>
138+
</label>
139+
140+
<label
141+
class="flex flex-col gap-1 text-sm font-medium text-surface-700 dark:text-surface-300"
142+
>
143+
<span>Confirm new password</span>
144+
<input
145+
v-model="confirmPassword"
146+
type="password"
147+
autocomplete="new-password"
148+
required
149+
minlength="8"
150+
class="px-3 py-2 border border-surface-300 dark:border-surface-700 rounded-md text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 outline-none transition-colors focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15"
151+
/>
152+
</label>
153+
154+
<button
155+
type="submit"
156+
:disabled="isLoading"
157+
class="mt-2 px-4 py-2.5 bg-brand-600 text-white rounded-md text-sm font-medium cursor-pointer hover:bg-brand-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
158+
>
159+
{{ isLoading ? "Resetting…" : "Reset password" }}
160+
</button>
161+
</form>
162+
163+
<p class="text-center text-sm text-surface-500 dark:text-surface-400">
164+
<NuxtLink
165+
:to="$localePath('/auth/sign-in')"
166+
class="text-brand-600 dark:text-brand-400 hover:underline"
167+
>
168+
Back to sign in
169+
</NuxtLink>
170+
</p>
171+
</template>
172+
</div>
173+
</template>

app/pages/auth/sign-in.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,15 @@ async function handleSocialSignIn(providerId: string) {
322322
/>
323323
</label>
324324

325+
<div class="flex justify-end -mt-2">
326+
<NuxtLink
327+
:to="$localePath('/auth/forgot-password')"
328+
class="text-sm text-brand-600 dark:text-brand-400 hover:underline"
329+
>
330+
Forgot password?
331+
</NuxtLink>
332+
</div>
333+
325334
<button
326335
type="submit"
327336
:disabled="isLoading"

nuxt.config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,6 @@ export default defineNuxtConfig({
251251
"X-Content-Type-Options": "nosniff",
252252
"X-Frame-Options": "DENY",
253253
"Referrer-Policy": "strict-origin-when-cross-origin",
254-
"X-XSS-Protection": "1; mode=block",
255254
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
256255
"Strict-Transport-Security":
257256
"max-age=63072000; includeSubDomains; preload",

server/api/sso/providers.post.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,49 @@ import { eq, and, ne } from 'drizzle-orm'
33
import { ssoProvider } from '~~/server/database/schema'
44
import { prefetchOidcEndpointOrigins } from '~~/server/utils/auth'
55

6+
// Hostname/IP ranges that must never be contacted server-side (SSRF prevention)
7+
const BLOCKED_ISSUER_HOSTNAMES = new Set([
8+
'localhost',
9+
'169.254.169.254', // AWS / Azure / DigitalOcean IMDS
10+
'metadata.google.internal', // GCP IMDS
11+
'metadata.internal',
12+
'instance-data',
13+
])
14+
15+
function isBlockedIssuerUrl(url: string): boolean {
16+
let hostname: string
17+
try {
18+
hostname = new URL(url).hostname.toLowerCase()
19+
} catch {
20+
return true
21+
}
22+
if (BLOCKED_ISSUER_HOSTNAMES.has(hostname)) return true
23+
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
24+
if (ipv4) {
25+
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])]
26+
if (a === 127 || a === 0) return true
27+
if (a === 10) return true
28+
if (a === 172 && b >= 16 && b <= 31) return true
29+
if (a === 192 && b === 168) return true
30+
if (a === 100 && b >= 64 && b <= 127) return true
31+
if (a === 169 && b === 254) return true
32+
}
33+
if (hostname === '::1') return true
34+
if (hostname.startsWith('fe80:')) return true
35+
return false
36+
}
37+
638
const registerSsoSchema = z.object({
739
providerId: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/, 'Only lowercase alphanumeric and hyphens'),
8-
issuer: z.string().url().refine(
9-
(url) => url.startsWith('https://') || url.startsWith('http://'),
10-
'Issuer URL must use HTTPS (or HTTP for local development)',
11-
),
40+
issuer: z.string().url()
41+
.refine(
42+
(url) => url.startsWith('https://') || url.startsWith('http://'),
43+
'Issuer URL must use HTTPS (or HTTP for local development)',
44+
)
45+
.refine(
46+
(url) => !isBlockedIssuerUrl(url),
47+
'Issuer URL must not target internal or private network addresses',
48+
),
1249
domain: z.string().min(1).max(253).regex(
1350
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/,
1451
'Must be a valid domain (e.g. company.com)',

0 commit comments

Comments
 (0)