feat(auth): add resend verification email to all login pages

Add ability to resend verification email when login fails with
"Email not verified" error. Implemented across all 14 apps using
Mana Core Auth.

Changes:
- Add POST /api/v1/auth/resend-verification endpoint to mana-core-auth
- Add resendVerificationEmail method to shared-auth client
- Update LoginPage component with resend UI and translations
- Add resendVerificationEmail to all app auth stores
- Add translations for de, en, fr, es, it
- Add PlantaLogo to shared-branding
- Migrate planta login to shared LoginPage component
This commit is contained in:
Till-JS 2026-01-29 14:55:49 +01:00
parent f911243bf0
commit 0c150df0f1
45 changed files with 691 additions and 110 deletions

View file

@ -230,6 +230,31 @@ export const authStore = {
}
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
// Pass the current app URL for post-verification redirect
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
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 (raw token, no refresh)
* @deprecated Use getValidToken() instead for automatic refresh

View file

@ -39,6 +39,10 @@
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -50,6 +54,7 @@
logo={CalendarLogo}
primaryColor="#0ea5e9"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -205,6 +205,30 @@ export const authStore = {
}
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get user credit balance
*/

View file

@ -46,6 +46,10 @@
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -57,6 +61,7 @@
logo={ChatLogo}
primaryColor="#0ea5e9"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -204,6 +204,30 @@ export const authStore = {
}
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
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 (raw token, no refresh)
* @deprecated Use getValidToken() instead for automatic refresh

View file

@ -205,6 +205,30 @@ export const authStore = {
}
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
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 (raw token, no refresh)
* @deprecated Use getValidToken() instead for automatic refresh

View file

@ -36,6 +36,10 @@
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -47,6 +51,7 @@
logo={ContactsLogo}
primaryColor="#3b82f6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -248,6 +248,30 @@ export const authStore = {
}
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
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 (raw token, no refresh)
* @deprecated Use getValidToken() instead for automatic refresh

View file

@ -19,6 +19,10 @@
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<LoginPage
@ -26,6 +30,7 @@
logo={ManaCoreLogo}
primaryColor="#6366f1"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -113,6 +113,14 @@ export const authStore = {
return authService.forgotPassword(email);
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const sourceAppUrl = browser ? window.location.origin : undefined;
return authService.resendVerificationEmail(email, sourceAppUrl);
},
/**
* Get access token for API calls (raw token, no refresh)
* @deprecated Use getValidToken() instead for automatic refresh

View file

@ -19,6 +19,10 @@
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<LoginPage
@ -26,6 +30,7 @@
logo={ManaDeckLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -190,6 +190,30 @@ export const authStore = {
}
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
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

@ -33,6 +33,10 @@
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -44,6 +48,7 @@
logo={NutriPhiLogo}
primaryColor="#22C55E"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -199,6 +199,32 @@ export const authStore = {
return await tokenManager.getValidToken();
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth service not available' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to resend verification email',
};
}
},
// For compatibility with old code that reads user store directly
setUser(userData: UserData | null) {
user = userData;

View file

@ -24,6 +24,10 @@
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
async function handleSignInWithGoogle() {
// TODO: Implement OAuth with Mana Core Auth when ready
return { success: false, error: 'Google Sign-In not yet implemented' };
@ -48,6 +52,7 @@
logo={PictureLogo}
primaryColor="#3b82f6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
onSignInWithGoogle={PUBLIC_GOOGLE_CLIENT_ID ? handleSignInWithGoogle : undefined}
onSignInWithApple={PUBLIC_APPLE_CLIENT_ID ? handleSignInWithApple : undefined}
{goto}

View file

@ -168,4 +168,28 @@ export const authStore = {
}
return await tokenManager.getValidToken();
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -2,12 +2,4 @@
let { children } = $props();
</script>
<div class="flex min-h-screen flex-col items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-foreground">Planta</h1>
<p class="mt-2 text-muted-foreground">Deine Pflanzen dokumentieren</p>
</div>
{@render children()}
</div>
</div>
{@render children()}

View file

@ -1,107 +1,63 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { PlantaLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
// Get redirect URL from query params or sessionStorage
const redirectTo = $derived.by(() => {
const queryRedirect = $page.url.searchParams.get('redirectTo');
if (queryRedirect) return queryRedirect;
if (browser) {
const sessionRedirect = sessionStorage.getItem('auth-return-url');
if (sessionRedirect) {
sessionStorage.removeItem('auth-return-url');
return sessionRedirect;
}
}
return '/dashboard';
});
// German translations (Planta is German-focused)
const translations = $derived(getLoginTranslations('de'));
// Read verification status from query params (set after email verification)
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmailFromUrl = $derived($page.url.searchParams.get('email') || '');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
let email = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
let showVerifiedBanner = $state(false);
// Initialize email from URL if provided
$effect(() => {
if (initialEmailFromUrl && !email) {
email = initialEmailFromUrl;
}
if (verified) {
showVerifiedBanner = true;
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
loading = true;
const result = await authStore.signIn(email, password);
if (result.success) {
goto('/dashboard');
} else {
error = result.error || 'Login fehlgeschlagen';
}
loading = false;
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
function dismissBanner() {
showVerifiedBanner = false;
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<form onsubmit={handleSubmit} class="space-y-4">
{#if showVerifiedBanner}
<div
class="relative rounded-md bg-green-100 p-3 text-sm text-green-800 dark:bg-green-900/30 dark:text-green-200"
>
<button
type="button"
onclick={dismissBanner}
class="absolute right-2 top-2 text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-200"
>
×
</button>
E-Mail erfolgreich bestätigt! Du kannst dich jetzt anmelden.
</div>
{/if}
<svelte:head>
<title>{translations.title} | Planta</title>
</svelte:head>
{#if error}
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-foreground">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="input mt-1 w-full"
placeholder="deine@email.de"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-foreground">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
required
class="input mt-1 w-full"
placeholder="••••••••"
/>
</div>
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
{#if loading}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
Anmelden
{/if}
</button>
<p class="text-center text-sm text-muted-foreground">
Noch kein Konto?
<a href="/register" class="text-primary hover:underline">Registrieren</a>
</p>
</form>
<LoginPage
appName="Planta"
logo={PlantaLogo}
primaryColor="#22c55e"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#dcfce7"
darkBackground="#052e16"
{translations}
{verified}
{initialEmail}
/>

View file

@ -190,4 +190,28 @@ export const auth = {
}
return await authService.getAppToken();
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -23,6 +23,10 @@
async function handleSignIn(email: string, password: string) {
return auth.login(email, password);
}
async function handleResendVerification(email: string) {
return auth.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -34,6 +38,7 @@
logo={PresiLogo}
primaryColor="#f97316"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -183,4 +183,28 @@ export const authStore = {
}
return await tokenManager.getValidToken();
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -39,6 +39,10 @@
}
return result;
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -50,6 +54,7 @@
logo={QuestionsLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -209,4 +209,28 @@ export const authStore = {
}
return await tokenManager.getValidToken();
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -39,6 +39,10 @@
}
return result;
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -50,6 +54,7 @@
logo={SkillTreeLogo}
primaryColor="#10b981"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}

View file

@ -158,4 +158,28 @@ export const authStore = {
}
return await authService.getAppToken();
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -18,6 +18,10 @@
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -29,6 +33,7 @@
logo={ManaIcon}
primaryColor="#3b82f6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
successRedirect="/files"
registerPath="/register"

View file

@ -250,4 +250,28 @@ export const authStore = {
}
return await tokenManager.getValidToken();
},
/**
* Resend verification email
*/
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to resend verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -38,6 +38,10 @@
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
@ -49,6 +53,7 @@
logo={TodoLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}