mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
♻️ refactor: migrate manacore-web from Supabase to mana-core-auth
- Add password reset functionality to mana-core-auth using Better Auth - Add forgot-password and reset-password endpoints with DTOs - Update shared-auth package with resetPassword method and endpoint - Update manacore-web auth store with resetPassword method - Refactor reset-password pages to use mana-core-auth instead of Supabase - Remove Supabase dependencies from manacore-web package.json - Remove Supabase server code (hooks.server.ts, supabase.ts, API routes) - Update Dockerfile to remove shared-supabase dependency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
48c5cb48f7
commit
ee091c4b10
23 changed files with 357 additions and 639 deletions
|
|
@ -35,7 +35,6 @@ COPY packages/shared-theme-ui ./packages/shared-theme-ui
|
|||
COPY packages/shared-subscription-types ./packages/shared-subscription-types
|
||||
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
|
||||
COPY packages/shared-profile-ui ./packages/shared-profile-ui
|
||||
COPY packages/shared-supabase ./packages/shared-supabase
|
||||
COPY packages/shared-types ./packages/shared-types
|
||||
COPY packages/shared-ui ./packages/shared-ui
|
||||
COPY packages/shared-utils ./packages/shared-utils
|
||||
|
|
|
|||
|
|
@ -49,15 +49,12 @@
|
|||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-subscription-types": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-supabase": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.0"
|
||||
},
|
||||
|
|
|
|||
23
apps/manacore/apps/web/src/app.d.ts
vendored
23
apps/manacore/apps/web/src/app.d.ts
vendored
|
|
@ -1,18 +1,15 @@
|
|||
import type { Session, SupabaseClient, User } from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
* App type declarations for ManaCore web app
|
||||
*
|
||||
* Authentication is handled entirely by Mana Core Auth (@manacore/shared-auth).
|
||||
* No Supabase is needed - all data comes from mana-core-auth APIs.
|
||||
*/
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
supabase: SupabaseClient;
|
||||
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
}
|
||||
interface PageData {
|
||||
// Auth is handled by Mana Core Auth (@manacore/shared-auth), not Supabase
|
||||
// Supabase is used for database operations only
|
||||
supabase?: SupabaseClient;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface Locals {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface PageData {}
|
||||
// interface Error {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,11 @@
|
|||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Server hooks for ManaCore web app
|
||||
*
|
||||
* Note: Authentication is handled client-side via Mana Core Auth.
|
||||
* Supabase is only used for database operations (not auth).
|
||||
* Authentication is handled entirely by Mana Core Auth (@manacore/shared-auth).
|
||||
* No Supabase is needed - all data comes from mana-core-auth APIs.
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Create Supabase client for database operations only
|
||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
cookies: {
|
||||
getAll: () => event.cookies.getAll(),
|
||||
setAll: (cookiesToSet) => {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
event.cookies.set(name, value, { ...options, path: '/' });
|
||||
});
|
||||
},
|
||||
},
|
||||
}) as any;
|
||||
|
||||
return resolve(event, {
|
||||
filterSerializedResponseHeaders(name) {
|
||||
return name === 'content-range' || name === 'x-supabase-api-version';
|
||||
},
|
||||
});
|
||||
return resolve(event);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function getUser(event: RequestEvent) {
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await event.locals.supabase.auth.getUser();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getSession(event: RequestEvent) {
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await event.locals.supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching session:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireAuth(event: RequestEvent) {
|
||||
const session = await getSession(event);
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export function getSupabaseServerClient(event: RequestEvent) {
|
||||
return event.locals.supabase;
|
||||
}
|
||||
|
|
@ -183,6 +183,29 @@ export const authStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
*/
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.resetPassword(token, newPassword);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access token for API calls
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals: { supabase }, url }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
if (!email) {
|
||||
return fail(400, {
|
||||
error: 'Email is required',
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the origin for the redirect URL
|
||||
const origin = url.origin;
|
||||
const redirectTo = `${origin}/auth/reset-password`;
|
||||
|
||||
// Send password reset email
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Password reset error:', error);
|
||||
return fail(400, {
|
||||
error: error.message,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
// Return success (we don't reveal if the email exists for security)
|
||||
return {
|
||||
success: true,
|
||||
email,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals: { supabase } }) => {
|
||||
const formData = await request.formData();
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
// Validate inputs
|
||||
if (!password || !confirmPassword) {
|
||||
return fail(400, {
|
||||
error: 'Both password fields are required',
|
||||
});
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return fail(400, {
|
||||
error: 'Password must be at least 6 characters long',
|
||||
});
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return fail(400, {
|
||||
error: 'Passwords do not match',
|
||||
});
|
||||
}
|
||||
|
||||
// Update the user's password
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Password update error:', error);
|
||||
return fail(400, {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Success - redirect to dashboard
|
||||
throw redirect(303, '/dashboard');
|
||||
},
|
||||
};
|
||||
|
|
@ -1,141 +1,103 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button, Input, Card } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
let loading = $state(false);
|
||||
let hasToken = $state(false);
|
||||
let verifying = $state(true);
|
||||
let verificationError = $state<string | null>(null);
|
||||
let token = $state<string | null>(null);
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
// Check if we have tokens in the URL hash (from password recovery email)
|
||||
const hash = window.location.hash.substring(1); // Remove the '#'
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
const accessToken = hashParams.get('access_token');
|
||||
const refreshToken = hashParams.get('refresh_token');
|
||||
const type = hashParams.get('type');
|
||||
|
||||
// Check if we have a token in the URL query params (from Supabase email link)
|
||||
const queryToken = $page.url.searchParams.get('token');
|
||||
const queryType = $page.url.searchParams.get('type');
|
||||
|
||||
if (accessToken && refreshToken && type === 'recovery') {
|
||||
// Have tokens in hash - need to establish session
|
||||
try {
|
||||
const response = await fetch('/api/auth/set-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
hasToken = true;
|
||||
// Clean up URL by removing hash
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
} else {
|
||||
verificationError = result.error || 'Failed to establish session';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session establishment error:', error);
|
||||
verificationError = 'Failed to establish session';
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
} else if (queryToken && queryType === 'recovery') {
|
||||
// Have token in query params - need to verify via OTP
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: queryToken, type: queryType }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
hasToken = true;
|
||||
// Clean up URL by removing query params
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
} else {
|
||||
verificationError = result.error || 'Invalid or expired reset link';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
verificationError = 'Failed to verify reset link';
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
} else {
|
||||
// No token found
|
||||
verifying = false;
|
||||
}
|
||||
onMount(() => {
|
||||
// Get token from URL query parameter
|
||||
token = $page.url.searchParams.get('token');
|
||||
hasToken = !!token;
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
|
||||
if (!token) {
|
||||
error = 'Reset token is missing';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 12) {
|
||||
error = 'Password must be at least 12 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const result = await authStore.resetPassword(token, password);
|
||||
|
||||
if (!result.success) {
|
||||
error = result.error || 'Failed to reset password';
|
||||
} else {
|
||||
success = true;
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
goto('/login');
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{#if verifying}
|
||||
Verifying your reset link...
|
||||
{#if success}
|
||||
Password reset successfully
|
||||
{:else if hasToken}
|
||||
Enter your new password
|
||||
{:else}
|
||||
Token missing or expired
|
||||
Invalid or missing reset token
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if verifying}
|
||||
{#if success}
|
||||
<Card class="mt-8">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
|
||||
></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Verifying your password reset link...</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if verificationError}
|
||||
<Card class="mt-8">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">⚠️</div>
|
||||
<div class="mb-4 text-6xl">✅</div>
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
{verificationError}
|
||||
Your password has been reset successfully. You will be redirected to the login page
|
||||
shortly.
|
||||
</p>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
href="/login"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
|
||||
>
|
||||
Request a new reset link
|
||||
Go to login
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if hasToken}
|
||||
<Card class="mt-8">
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
loading = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#if form?.error}
|
||||
<form onsubmit={handleSubmit}>
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{form.error}
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -152,12 +114,13 @@
|
|||
name="password"
|
||||
id="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
minlength={6}
|
||||
minlength={12}
|
||||
bind:value={password}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must be at least 6 characters
|
||||
Must be at least 12 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -173,9 +136,10 @@
|
|||
name="confirmPassword"
|
||||
id="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
minlength={6}
|
||||
minlength={12}
|
||||
bind:value={confirmPassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -195,10 +159,10 @@
|
|||
This password reset link is invalid or has expired.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
href="/forgot-password"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
|
||||
>
|
||||
Back to login
|
||||
Request a new reset link
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
return {
|
||||
cookies: cookies.getAll(),
|
||||
};
|
||||
/**
|
||||
* Server layout load - minimal, auth handled by mana-core-auth client-side
|
||||
*/
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,35 +1,14 @@
|
|||
import { waitLocale } from '$lib/i18n';
|
||||
import '$lib/i18n'; // This triggers the init() call at module scope
|
||||
import { createBrowserClient } from '@supabase/ssr';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load: LayoutLoad = async ({ data, depends }) => {
|
||||
/**
|
||||
* Layout load function
|
||||
*
|
||||
* Auth is handled entirely by Mana Core Auth (@manacore/shared-auth).
|
||||
* No Supabase is needed - all data comes from mana-core-auth APIs.
|
||||
*/
|
||||
export const load: LayoutLoad = async () => {
|
||||
await waitLocale();
|
||||
|
||||
/**
|
||||
* Declare a dependency so the layout will be invalidated when `invalidate('supabase:auth')` is called.
|
||||
*/
|
||||
depends('supabase:auth');
|
||||
|
||||
// Create Supabase client for database operations only
|
||||
// Auth is handled by Mana Core Auth (@manacore/shared-auth)
|
||||
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
global: {
|
||||
fetch,
|
||||
},
|
||||
cookies: {
|
||||
getAll() {
|
||||
return data.cookies;
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
// Browser client handles cookies automatically through the browser
|
||||
// This is a no-op as cookies are managed via document.cookie in the browser
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Note: Auth session is managed by Mana Core Auth via authStore,
|
||||
// not Supabase auth. Supabase is used for database operations only.
|
||||
return { supabase };
|
||||
return {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals: { supabase } }) => {
|
||||
try {
|
||||
const { access_token, refresh_token } = await request.json();
|
||||
|
||||
if (!access_token || !refresh_token) {
|
||||
return json(
|
||||
{ success: false, error: 'Access token and refresh token are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set the session using the tokens from the URL hash
|
||||
const { data, error } = await supabase.auth.setSession({
|
||||
access_token,
|
||||
refresh_token,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Set session error:', error);
|
||||
return json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!data.session) {
|
||||
return json({ success: false, error: 'Failed to create session' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Session is now set via cookies by the Supabase client
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Unexpected error in set session:', error);
|
||||
return json({ success: false, error: 'An unexpected error occurred' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals: { supabase } }) => {
|
||||
try {
|
||||
const { token, type } = await request.json();
|
||||
|
||||
if (!token || type !== 'recovery') {
|
||||
return json({ success: false, error: 'Invalid token or type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify the OTP token and create a session
|
||||
const { data, error } = await supabase.auth.verifyOtp({
|
||||
token_hash: token,
|
||||
type: 'recovery',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!data.session) {
|
||||
return json({ success: false, error: 'Failed to create session' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Session is now set via cookies by the Supabase client
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Unexpected error in token verification:', error);
|
||||
return json({ success: false, error: 'An unexpected error occurred' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals: { supabase } }) => {
|
||||
const formData = await request.formData();
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
// Validate inputs
|
||||
if (!password || !confirmPassword) {
|
||||
return fail(400, {
|
||||
error: 'Both password fields are required',
|
||||
});
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return fail(400, {
|
||||
error: 'Password must be at least 6 characters long',
|
||||
});
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return fail(400, {
|
||||
error: 'Passwords do not match',
|
||||
});
|
||||
}
|
||||
|
||||
// Update the user's password
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Password update error:', error);
|
||||
return fail(400, {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Success - redirect to dashboard
|
||||
throw redirect(303, '/dashboard');
|
||||
},
|
||||
};
|
||||
|
|
@ -1,214 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button, Input, Card } from '@manacore/shared-ui';
|
||||
|
||||
let { form, data } = $props();
|
||||
let loading = $state(false);
|
||||
let hasToken = $state(false);
|
||||
let verifying = $state(true);
|
||||
let verificationError = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
// Check if we have tokens in the URL hash (from password recovery email)
|
||||
const hash = window.location.hash.substring(1); // Remove the '#'
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
const accessToken = hashParams.get('access_token');
|
||||
const refreshToken = hashParams.get('refresh_token');
|
||||
const type = hashParams.get('type');
|
||||
|
||||
// Check if we have a token in the URL query params (from Supabase email link)
|
||||
const queryToken = $page.url.searchParams.get('token');
|
||||
const queryType = $page.url.searchParams.get('type');
|
||||
|
||||
if (accessToken && refreshToken && type === 'recovery') {
|
||||
// Have tokens in hash - need to establish session
|
||||
try {
|
||||
const response = await fetch('/api/auth/set-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
hasToken = true;
|
||||
// Clean up URL by removing hash
|
||||
window.history.replaceState({}, '', '/auth/reset-password');
|
||||
} else {
|
||||
verificationError = result.error || 'Failed to establish session';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session establishment error:', error);
|
||||
verificationError = 'Failed to establish session';
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
} else if (queryToken && queryType === 'recovery') {
|
||||
// Have token in query params - need to verify via OTP
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: queryToken, type: queryType }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
hasToken = true;
|
||||
// Clean up URL by removing query params
|
||||
window.history.replaceState({}, '', '/auth/reset-password');
|
||||
} else {
|
||||
verificationError = result.error || 'Invalid or expired reset link';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
verificationError = 'Failed to verify reset link';
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
} else {
|
||||
// No token found
|
||||
verifying = false;
|
||||
}
|
||||
// Redirect to the main reset-password page, preserving the token query parameter
|
||||
onMount(() => {
|
||||
const token = $page.url.searchParams.get('token');
|
||||
const redirectUrl = token ? `/reset-password?token=${token}` : '/reset-password';
|
||||
goto(redirectUrl, { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reset Password - ManaCore</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center">
|
||||
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{#if verifying}
|
||||
Verifying your reset link...
|
||||
{:else if hasToken}
|
||||
Enter your new password
|
||||
{:else}
|
||||
Token missing or expired
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if verifying}
|
||||
<Card class="mt-8">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
|
||||
></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Verifying your password reset link...</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if verificationError}
|
||||
<Card class="mt-8">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">⚠️</div>
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
{verificationError}
|
||||
</p>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
|
||||
>
|
||||
Request a new reset link
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if hasToken}
|
||||
<Card class="mt-8">
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
loading = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#if form?.error}
|
||||
<div
|
||||
class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength={6}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must be at least 6 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
id="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" {loading} class="w-full">
|
||||
{loading ? 'Updating password...' : 'Update password'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="mt-8">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">⚠️</div>
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
This password reset link is invalid or has expired.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
|
||||
>
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
|
||||
></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Redirecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const DEFAULT_ENDPOINTS: AuthEndpoints = {
|
|||
refresh: '/api/v1/auth/refresh',
|
||||
validate: '/api/v1/auth/validate',
|
||||
forgotPassword: '/api/v1/auth/forgot-password',
|
||||
resetPassword: '/api/v1/auth/reset-password',
|
||||
googleSignIn: '/api/v1/auth/google-signin',
|
||||
appleSignIn: '/api/v1/auth/apple-signin',
|
||||
credits: '/api/v1/credits/balance',
|
||||
|
|
@ -192,6 +193,41 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
*/
|
||||
async resetPassword(token: string, newPassword: string): Promise<AuthResult> {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${endpoints.resetPassword}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (errorData.message?.includes('expired')) {
|
||||
return { success: false, error: 'Reset link has expired. Please request a new one.' };
|
||||
}
|
||||
|
||||
if (errorData.message?.includes('invalid')) {
|
||||
return { success: false, error: 'Invalid reset link. Please request a new one.' };
|
||||
}
|
||||
|
||||
return { success: false, error: errorData.message || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error resetting password:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during password reset',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the authentication tokens
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export interface AuthEndpoints {
|
|||
refresh: string;
|
||||
validate: string;
|
||||
forgotPassword: string;
|
||||
resetPassword: string;
|
||||
googleSignIn: string;
|
||||
appleSignIn: string;
|
||||
credits: string;
|
||||
|
|
|
|||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
|
@ -1442,9 +1442,6 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-supabase':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-supabase
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -1463,12 +1460,6 @@ importers:
|
|||
'@manacore/shared-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-utils
|
||||
'@supabase/ssr':
|
||||
specifier: ^0.5.2
|
||||
version: 0.5.2(@supabase/supabase-js@2.84.0)
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.81.1
|
||||
version: 2.84.0
|
||||
svelte-dnd-action:
|
||||
specifier: ^0.9.68
|
||||
version: 0.9.68(svelte@5.44.0)
|
||||
|
|
@ -9042,11 +9033,6 @@ packages:
|
|||
resolution: {integrity: sha512-ThqjxiCwWiZAroHnYPmnNl6tZk6jxGcG2a7Hp/3kcolPcMj89kWjUTA3cHmhdIWYsP84fHp8MAQjYWMLf7HEUg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@supabase/ssr@0.5.2':
|
||||
resolution: {integrity: sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==}
|
||||
peerDependencies:
|
||||
'@supabase/supabase-js': ^2.43.4
|
||||
|
||||
'@supabase/ssr@0.7.0':
|
||||
resolution: {integrity: sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==}
|
||||
peerDependencies:
|
||||
|
|
@ -26238,12 +26224,6 @@ snapshots:
|
|||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@supabase/ssr@0.5.2(@supabase/supabase-js@2.84.0)':
|
||||
dependencies:
|
||||
'@supabase/supabase-js': 2.84.0
|
||||
'@types/cookie': 0.6.0
|
||||
cookie: 0.7.2
|
||||
|
||||
'@supabase/ssr@0.7.0(@supabase/supabase-js@2.84.0)':
|
||||
dependencies:
|
||||
'@supabase/supabase-js': 2.84.0
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { RegisterB2BDto } from './dto/register-b2b.dto';
|
|||
import { InviteEmployeeDto } from './dto/invite-employee.dto';
|
||||
import { AcceptInvitationDto } from './dto/accept-invitation.dto';
|
||||
import { SetActiveOrganizationDto } from './dto/set-active-organization.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
|
||||
/**
|
||||
|
|
@ -137,6 +139,39 @@ export class AuthController {
|
|||
return this.betterAuthService.getJwks();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Password Reset Endpoints
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
*
|
||||
* Initiates the password reset flow by sending an email with a reset link.
|
||||
* Always returns success to prevent email enumeration attacks.
|
||||
*/
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
|
||||
return this.betterAuthService.requestPasswordReset(
|
||||
forgotPasswordDto.email,
|
||||
forgotPasswordDto.redirectTo
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
*
|
||||
* Completes the password reset using the token from the email link.
|
||||
*/
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
|
||||
return this.betterAuthService.resetPassword(
|
||||
resetPasswordDto.token,
|
||||
resetPasswordDto.newPassword
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// B2B Registration
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -80,12 +80,36 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
},
|
||||
}),
|
||||
|
||||
// Email/password authentication only
|
||||
// Email/password authentication with password reset
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false, // Can enable later
|
||||
minPasswordLength: 12,
|
||||
maxPasswordLength: 128,
|
||||
|
||||
/**
|
||||
* Password Reset Configuration
|
||||
*
|
||||
* Better Auth provides password reset via:
|
||||
* - auth.api.forgetPassword({ email }) - Sends reset email
|
||||
* - auth.api.resetPassword({ newPassword, token }) - Resets password
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
*/
|
||||
sendResetPassword: async ({ user, url, token }) => {
|
||||
// TODO: Implement email sending service (e.g., Resend, SendGrid)
|
||||
// For now, log the reset URL for development
|
||||
console.log('[Password Reset] User:', user.email);
|
||||
console.log('[Password Reset] Reset URL:', url);
|
||||
console.log('[Password Reset] Token:', token);
|
||||
|
||||
// In production, send an email like:
|
||||
// await sendEmail({
|
||||
// to: user.email,
|
||||
// subject: 'Reset your password',
|
||||
// html: `<a href="${url}">Reset your password</a>`
|
||||
// });
|
||||
},
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
|
|
|
|||
22
services/mana-core-auth/src/auth/dto/forgot-password.dto.ts
Normal file
22
services/mana-core-auth/src/auth/dto/forgot-password.dto.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { IsEmail, IsOptional, IsString, IsUrl } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Forgot Password DTO
|
||||
*
|
||||
* Request body for initiating password reset.
|
||||
*/
|
||||
export class ForgotPasswordDto {
|
||||
/**
|
||||
* User's email address
|
||||
*/
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* Optional redirect URL after password reset
|
||||
* The reset token will be appended as a query parameter
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirectTo?: string;
|
||||
}
|
||||
22
services/mana-core-auth/src/auth/dto/reset-password.dto.ts
Normal file
22
services/mana-core-auth/src/auth/dto/reset-password.dto.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Reset Password DTO
|
||||
*
|
||||
* Request body for resetting password with token.
|
||||
*/
|
||||
export class ResetPasswordDto {
|
||||
/**
|
||||
* Reset token from email link
|
||||
*/
|
||||
@IsString()
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* New password (must meet password requirements)
|
||||
*/
|
||||
@IsString()
|
||||
@MinLength(12, { message: 'Password must be at least 12 characters long' })
|
||||
@MaxLength(128, { message: 'Password must be at most 128 characters long' })
|
||||
newPassword: string;
|
||||
}
|
||||
|
|
@ -845,6 +845,92 @@ export class BetterAuthService {
|
|||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Password Reset Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
*
|
||||
* Sends a password reset email to the user.
|
||||
* Uses Better Auth's forgetPassword API.
|
||||
*
|
||||
* @param email - User's email address
|
||||
* @param redirectTo - Optional URL to redirect after reset (used in email link)
|
||||
* @returns Success status
|
||||
*/
|
||||
async requestPasswordReset(
|
||||
email: string,
|
||||
redirectTo?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// Better Auth's forgetPassword method
|
||||
// See: https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
await (this.auth.api as any).forgetPassword({
|
||||
body: {
|
||||
email,
|
||||
redirectTo,
|
||||
},
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
return {
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[requestPasswordReset] Error:', error);
|
||||
// Always return success to prevent email enumeration attacks
|
||||
return {
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
*
|
||||
* Resets the user's password using the token from the reset email.
|
||||
* Uses Better Auth's resetPassword API.
|
||||
*
|
||||
* @param token - Reset token from email link
|
||||
* @param newPassword - New password to set
|
||||
* @returns Success status
|
||||
* @throws UnauthorizedException if token is invalid or expired
|
||||
*/
|
||||
async resetPassword(
|
||||
token: string,
|
||||
newPassword: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// Better Auth's resetPassword method
|
||||
// See: https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
await (this.auth.api as any).resetPassword({
|
||||
body: {
|
||||
token,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Password has been reset successfully',
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message?.includes('invalid') ||
|
||||
error.message?.includes('expired') ||
|
||||
error.message?.includes('not found')
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid or expired reset token');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWKS (JSON Web Key Set)
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue