♻️ 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:
Wuesteon 2025-12-08 17:04:35 +01:00
parent 48c5cb48f7
commit ee091c4b10
23 changed files with 357 additions and 639 deletions

View file

@ -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

View file

@ -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"
},

View file

@ -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 {}
}

View file

@ -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);
};

View file

@ -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;
}

View file

@ -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
*/

View file

@ -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,
};
},
};

View file

@ -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');
},
};

View file

@ -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>

View file

@ -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 {};
};

View file

@ -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 {};
};

View file

@ -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 });
}
};

View file

@ -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 });
}
};

View file

@ -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');
},
};

View file

@ -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>