feat(questions): unify auth pages with shared components

- Add Questions branding to shared-branding package (logo, colors)
- Create QuestionsLogo.svelte component
- Refactor login page to use shared LoginPage component
- Refactor register page to use shared RegisterPage component
- Refactor forgot-password page to use shared ForgotPasswordPage component
- Fix Svelte 5 class: directive on components (+page.svelte)

The Questions app now uses the same auth UI as Calendar, Chat, and other apps.
This commit is contained in:
Till-JS 2026-01-29 13:30:37 +01:00
parent da4b1e696b
commit 91143a497b
9 changed files with 136 additions and 255 deletions

View file

@ -142,8 +142,9 @@
<!-- Status Icon -->
<div class="mt-1">
<StatusIcon
class="h-5 w-5 {statusColor}"
class:animate-spin={question.status === 'researching'}
class="h-5 w-5 {statusColor} {question.status === 'researching'
? 'animate-spin'
: ''}"
/>
</div>

View file

@ -1,81 +1,30 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import { QuestionsLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { ArrowLeft } from '@manacore/shared-icons';
let email = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
let success = $state(false);
// Get translations (default to English)
const translations = $derived(getForgotPasswordTranslations('en'));
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
loading = true;
const result = await authStore.resetPassword(email);
if (result.success) {
success = true;
} else {
error = result.error || 'Failed to send reset email';
}
loading = false;
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<div class="rounded-xl bg-card p-8 shadow-lg">
<div class="mb-8 text-center">
<h1 class="text-2xl font-bold text-foreground">Reset Password</h1>
<p class="mt-2 text-muted-foreground">Enter your email to receive a reset link</p>
</div>
<svelte:head>
<title>{translations.titleForm} | Questions</title>
</svelte:head>
{#if success}
<div class="text-center">
<div class="mb-4 text-4xl">📧</div>
<h2 class="mb-2 text-lg font-semibold text-foreground">Check your email</h2>
<p class="mb-4 text-muted-foreground">
We've sent a password reset link to <strong>{email}</strong>. Please check your inbox.
</p>
<a href="/login" class="text-primary hover:underline">Back to login</a>
</div>
{:else}
<form onsubmit={handleSubmit} class="space-y-4">
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div>
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
<input
type="email"
id="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="you@example.com"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<div class="mt-6 text-center">
<a
href="/login"
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="h-4 w-4" />
Back to login
</a>
</div>
{/if}
</div>
<ForgotPasswordPage
appName="Questions"
logo={QuestionsLogo}
primaryColor="#8b5cf6"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#f3e8ff"
darkBackground="#1e1033"
{translations}
/>

View file

@ -1,81 +1,64 @@
<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 { QuestionsLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client';
let email = $state('');
let password = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
// Get redirect URL from query params or sessionStorage
const redirectTo = $derived.by(() => {
const queryRedirect = $page.url.searchParams.get('redirectTo');
if (queryRedirect) return queryRedirect;
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
loading = true;
if (browser) {
const sessionRedirect = sessionStorage.getItem('auth-return-url');
if (sessionRedirect) {
sessionStorage.removeItem('auth-return-url');
return sessionRedirect;
}
}
return '/';
});
// Get translations (default to English)
const translations = $derived(getLoginTranslations('en'));
// Read verification status from query params
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
async function handleSignIn(email: string, password: string) {
const result = await authStore.signIn(email, password);
if (result.success) {
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
goto('/');
} else {
error = result.error || 'Login failed';
}
loading = false;
return result;
}
</script>
<div class="rounded-xl bg-card p-8 shadow-lg">
<div class="mb-8 text-center">
<h1 class="text-2xl font-bold text-foreground">Questions</h1>
<p class="mt-2 text-muted-foreground">Sign in to your account</p>
</div>
<svelte:head>
<title>{translations.title} | Questions</title>
</svelte:head>
<form onsubmit={handleSubmit} class="space-y-4">
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div>
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
<input
type="email"
id="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="you@example.com"
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label>
<input
type="password"
id="password"
bind:value={password}
required
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<div class="mt-6 text-center text-sm text-muted-foreground">
<a href="/forgot-password" class="text-primary hover:underline">Forgot password?</a>
<span class="mx-2">·</span>
<a href="/register" class="text-primary hover:underline">Create account</a>
</div>
</div>
<LoginPage
appName="Questions"
logo={QuestionsLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f3e8ff"
darkBackground="#1e1033"
{translations}
{verified}
{initialEmail}
/>

View file

@ -1,125 +1,44 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { QuestionsLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client';
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state<string | null>(null);
let loading = $state(false);
let needsVerification = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 8) {
error = 'Password must be at least 8 characters';
return;
}
loading = true;
const result = await authStore.signUp(email, password);
if (result.success) {
if (result.needsVerification) {
needsVerification = true;
} else {
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
goto('/');
// Get redirect URL from sessionStorage
const redirectTo = $derived.by(() => {
if (browser) {
const sessionRedirect = sessionStorage.getItem('auth-return-url');
if (sessionRedirect) {
sessionStorage.removeItem('auth-return-url');
return sessionRedirect;
}
} else {
error = result.error || 'Registration failed';
}
return '/';
});
loading = false;
// Get translations (default to English)
const translations = $derived(getRegisterTranslations('en'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<div class="rounded-xl bg-card p-8 shadow-lg">
<div class="mb-8 text-center">
<h1 class="text-2xl font-bold text-foreground">Questions</h1>
<p class="mt-2 text-muted-foreground">Create your account</p>
</div>
<svelte:head>
<title>{translations.title} | Questions</title>
</svelte:head>
{#if needsVerification}
<div class="text-center">
<div class="mb-4 text-4xl">📧</div>
<h2 class="mb-2 text-lg font-semibold">Check your email</h2>
<p class="text-muted-foreground">
We've sent a verification link to <strong>{email}</strong>. Please check your inbox and
click the link to verify your account.
</p>
<a href="/login" class="mt-4 inline-block text-primary hover:underline">Back to login</a>
</div>
{:else}
<form onsubmit={handleSubmit} class="space-y-4">
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div>
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
<input
type="email"
id="email"
bind:value={email}
required
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="you@example.com"
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label
>
<input
type="password"
id="password"
bind:value={password}
required
minlength="8"
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<div>
<label for="confirmPassword" class="mb-1 block text-sm font-medium text-foreground"
>Confirm Password</label
>
<input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
required
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<div class="mt-6 text-center text-sm text-muted-foreground">
Already have an account?
<a href="/login" class="text-primary hover:underline">Sign in</a>
</div>
{/if}
</div>
<RegisterPage
appName="Questions"
logo={QuestionsLogo}
primaryColor="#8b5cf6"
onSignUp={handleSignUp}
{goto}
successRedirect={redirectTo}
loginPath="/login"
lightBackground="#f3e8ff"
darkBackground="#1e1033"
{translations}
/>

View file

@ -220,6 +220,19 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
questions: {
id: 'questions',
name: 'Questions',
tagline: 'AI Research Assistant',
primaryColor: '#8b5cf6',
secondaryColor: '#a78bfa',
// Question mark / search icon
logoPath:
'M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
};
/**

View file

@ -31,6 +31,7 @@ export {
MoodlitLogo,
InventoryLogo,
ClockLogo,
QuestionsLogo,
} from './logos';
// Configuration

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="questions" {size} {color} class={className} />

View file

@ -18,3 +18,4 @@ export { default as MailLogo } from './MailLogo.svelte';
export { default as MoodlitLogo } from './MoodlitLogo.svelte';
export { default as InventoryLogo } from './InventoryLogo.svelte';
export { default as ClockLogo } from './ClockLogo.svelte';
export { default as QuestionsLogo } from './QuestionsLogo.svelte';

View file

@ -18,7 +18,8 @@ export type AppId =
| 'todo'
| 'mail'
| 'moodlit'
| 'inventory';
| 'inventory'
| 'questions';
/**
* App branding configuration