mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 17:06:41 +02:00
Commit Message feat: implement comprehensive shared packages architecture for monorepo SUMMARY: Introduce 10 shared packages to unify common code across all 4 web apps, reducing ~3,000 lines of duplicated code and establishing consistent patterns for authentication, UI components, theming, and utilities. NEW SHARED PACKAGES: - @manacore/shared-auth: Unified auth logic (token management, JWT utils, fetch interceptor, storage/device/network adapters) - @manacore/shared-auth-ui: Reusable auth UI (LoginPage, RegisterPage, OAuth buttons for Google/Apple) - @manacore/shared-tailwind: Unified Tailwind config with 4 themes (lume, nature, stone, ocean) and light/dark mode support - @manacore/shared-icons: Phosphor-based icon library (40+ icons) - @manacore/shared-ui: Atomic design system (Text, Button, Badge, Toggle, Input, Modal) - @manacore/shared-i18n: Unified i18n setup with locale detection - @manacore/shared-config: Environment validation with Zod - @manacore/shared-subscriptio n-types: Subscription type definitions - @manacore/shared-subscriptio n-ui: Subscription UI components (planned) EXTENDED PACKAGES: - @manacore/shared-types: Added auth.ts, theme.ts, ui.ts, common.ts - @manacore/shared-utils: Added format.ts, validation.ts APP MIGRATIONS: - memoro/web: Migrated login (549→46 LOC), tailwind (165→12 LOC), removed 15+ duplicate components - manacore/web: Migrated to client-side auth with shared-auth, added new components (Icon, ThemeToggle, Logo) - manadeck/web: Replaced local authService/tokenManager with shared-auth, migrated auth pages - maerchenzauber/web: Added auth setup, stores, components, routes DELETED FILES (migrated to shared packages): - OAuth buttons (Google/Apple) from memoro, manacore, manadeck - Local authService, tokenManager, deviceManager, jwt utils - Duplicate Modal, Toggle, Text components - iconPaths and ManaIcon components - Subscription-related components (CostCard, PackageCard, etc.) BENEFITS: - 92% reduction in login page code - 93% reduction in tailwind config code - Consistent theming across all apps - Single source of truth for auth logic - Easier maintenance and updates BREAKING CHANGES: - Icon imports now from @manacore/shared-icons - Modal imports from @manacore/shared-ui - OAuth config via setGoogleCl ientId()/setAppleConfig()
This commit is contained in:
parent
725db638ea
commit
ef70a1af0b
198 changed files with 11113 additions and 3656 deletions
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { initializeAppleAuth, signInWithApple, waitForAppleAuth } from '../utils/appleAuth';
|
||||
|
||||
interface Props {
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
let { onError }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let sdkLoaded = $state(false);
|
||||
|
||||
async function handleAppleSignIn() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await signInWithApple();
|
||||
} catch (err) {
|
||||
console.error('Error initiating Apple Sign-In:', err);
|
||||
error = err instanceof Error ? err.message : 'Failed to initiate Apple Sign-In';
|
||||
onError?.(err instanceof Error ? err : new Error('Unknown error during Apple Sign-In'));
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await waitForAppleAuth();
|
||||
const initialized = initializeAppleAuth();
|
||||
if (initialized) {
|
||||
sdkLoaded = true;
|
||||
} else {
|
||||
console.warn('Apple Sign-In not configured - hiding button');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading Apple Sign-In:', err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if sdkLoaded}
|
||||
<div class="space-y-3">
|
||||
{#if error}
|
||||
<div class="rounded-xl bg-red-500/20 border border-red-500/30 p-3 text-sm text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleAppleSignIn}
|
||||
disabled={isLoading}
|
||||
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl bg-black border border-gray-800 px-4 font-medium text-white transition-all hover:bg-gray-900 disabled:opacity-50"
|
||||
>
|
||||
{#if isLoading}
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
{:else}
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{isLoading ? 'Signing in...' : 'Continue with Apple'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { initializeGoogleAuth, renderGoogleButton, waitForGoogleAuth } from '../utils/googleAuth';
|
||||
|
||||
interface Props {
|
||||
onSuccess: (idToken: string) => Promise<void>;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
let { onSuccess, onError }: Props = $props();
|
||||
|
||||
let buttonContainer: HTMLDivElement;
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function handleGoogleSignIn(idToken: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await onSuccess(idToken);
|
||||
} catch (err) {
|
||||
console.error('Error during Google Sign-In:', err);
|
||||
error = err instanceof Error ? err.message : 'Google Sign-In failed';
|
||||
onError?.(err instanceof Error ? err : new Error('Unknown error during Google Sign-In'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await waitForGoogleAuth();
|
||||
initializeGoogleAuth(handleGoogleSignIn);
|
||||
|
||||
if (buttonContainer) {
|
||||
renderGoogleButton(buttonContainer, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'signin_with',
|
||||
shape: 'pill'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error initializing Google Sign-In:', err);
|
||||
error = 'Failed to load Google Sign-In';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-xl bg-red-500/20 border border-red-500/30 p-3 text-sm text-red-500 mb-2">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div bind:this={buttonContainer} class="relative w-full google-btn-wrapper" style="min-height: 56px;">
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 flex items-center justify-center rounded-xl bg-white/80 dark:bg-black/80 backdrop-blur-sm z-10">
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.google-btn-wrapper > div) {
|
||||
width: 100% !important;
|
||||
height: 56px !important;
|
||||
}
|
||||
|
||||
:global(.google-btn-wrapper iframe) {
|
||||
height: 56px !important;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
</style>
|
||||
30
packages/shared-auth-ui/src/components/Icon.svelte
Normal file
30
packages/shared-auth-ui/src/components/Icon.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import { iconPaths, type IconName } from '../icons/iconPaths';
|
||||
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
class?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
let { name, size = 24, class: className = '', color }: Props = $props();
|
||||
|
||||
const path = $derived(iconPaths[name]);
|
||||
</script>
|
||||
|
||||
{#if path}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color || 'currentColor'}
|
||||
viewBox="0 0 256 256"
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html path}
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="text-red-500" title="Icon '{name}' not found">⚠</span>
|
||||
{/if}
|
||||
37
packages/shared-auth-ui/src/icons/iconPaths.ts
Normal file
37
packages/shared-auth-ui/src/icons/iconPaths.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Phosphor Icons (Bold weight) SVG paths
|
||||
* Only includes icons used in auth UI
|
||||
*/
|
||||
export const iconPaths = {
|
||||
'user-plus': '<path d="M256,136a8,8,0,0,1-8,8H232v16a8,8,0,0,1-16,0V144H200a8,8,0,0,1,0-16h16V112a8,8,0,0,1,16,0v16h16A8,8,0,0,1,256,136Zm-57.87,58.85a8,8,0,0,1-12.26,10.3C165.75,181.19,138.09,168,108,168s-57.75,13.19-77.87,37.15a8,8,0,0,1-12.26-10.3C32.09,177.09,52.67,163.91,76,157.53a68,68,0,1,1,64,0C163.33,163.91,183.91,177.09,198.13,194.85ZM108,152a52,52,0,1,0-52-52A52.06,52.06,0,0,0,108,152Z"/>',
|
||||
|
||||
'sign-in': '<path d="M144,136v-16a8,8,0,0,0-8-8H48a8,8,0,0,0-8,8v16a8,8,0,0,0,8,8h88A8,8,0,0,0,144,136Zm-27.31,52.69a8,8,0,0,0-11.32,11.31L136,230.63V224a8,8,0,0,0-16,0v24a8,8,0,0,0,8,8h24a8,8,0,0,0,0-16h-6.63l30.32-30.31a8,8,0,0,0-11.31-11.32l-24,24ZM200,32H136a8,8,0,0,0,0,16h64V208H136a8,8,0,0,0,0,16h64a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Z"/>',
|
||||
|
||||
'eye': '<path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/>',
|
||||
|
||||
'eye-off': '<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.58A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"/>',
|
||||
|
||||
'key': '<path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/>',
|
||||
|
||||
'arrow-left': '<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/>',
|
||||
|
||||
'info': '<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/>',
|
||||
|
||||
'mail-open': '<path d="M228.44,89.34l-96-64a8,8,0,0,0-8.88,0l-96,64A8,8,0,0,0,24,96V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V96A8,8,0,0,0,228.44,89.34ZM128,41.61l81.91,54.61-67,47.78a8,8,0,0,1-9.79,0l-67-47.78ZM40,200V111.53l65.9,47a24,24,0,0,0,29.44,0l65.9-47L216,200Z"/>',
|
||||
|
||||
'lock': '<path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/>',
|
||||
|
||||
'shield-check': '<path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,26.07,47.48,26.53a8,8,0,0,0,5.18,0c1.52-.46,24.42-7.67,47.48-26.53C200.48,196.67,226,164.72,226,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.58,89.42A132.87,132.87,0,0,1,128,224.54a132.77,132.77,0,0,1-39.42-23.12C61.66,179.16,48,149.07,48,112V56H208ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z"/>',
|
||||
|
||||
'arrows-left-right': '<path d="M45.66,77.66A8,8,0,0,1,40,64h80a8,8,0,0,1,0,16H59.31l18.35,18.34a8,8,0,0,1-11.32,11.32ZM216,176H136a8,8,0,0,0,0,16h60.69l-18.35,18.34a8,8,0,0,0,11.32,11.32l32-32a8,8,0,0,0,0-11.32l-32-32a8,8,0,0,0-11.32,11.32L196.69,176Z"/>',
|
||||
|
||||
'envelope': '<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/>',
|
||||
|
||||
'folder': '<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z"/>',
|
||||
|
||||
'music': '<path d="M212.92,17.69a8,8,0,0,0-6.86-1.45l-128,32A8,8,0,0,0,72,56V166.09A36,36,0,1,0,88,196V62.25l112-28v99.84A36,36,0,1,0,216,168V24A8,8,0,0,0,212.92,17.69ZM52,216a20,20,0,1,1,20-20A20,20,0,0,1,52,216Zm128-32a20,20,0,1,1,20-20A20,20,0,0,1,180,184Z"/>',
|
||||
|
||||
'refresh': '<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64A80,80,0,1,0,128,208a8,8,0,0,1,0,16A96,96,0,1,1,195.26,60.49L224,85.34V56a8,8,0,0,1,16,0Z"/>'
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof iconPaths;
|
||||
40
packages/shared-auth-ui/src/index.ts
Normal file
40
packages/shared-auth-ui/src/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Pages
|
||||
export { default as LoginPage } from './pages/LoginPage.svelte';
|
||||
export { default as RegisterPage } from './pages/RegisterPage.svelte';
|
||||
|
||||
// Components
|
||||
export { default as Icon } from './components/Icon.svelte';
|
||||
export { default as GoogleSignInButton } from './components/GoogleSignInButton.svelte';
|
||||
export { default as AppleSignInButton } from './components/AppleSignInButton.svelte';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
setGoogleClientId,
|
||||
initializeGoogleAuth,
|
||||
renderGoogleButton,
|
||||
isGoogleAuthLoaded,
|
||||
waitForGoogleAuth
|
||||
} from './utils/googleAuth';
|
||||
|
||||
export {
|
||||
setAppleConfig,
|
||||
initializeAppleAuth,
|
||||
signInWithApple,
|
||||
parseAppleAuthorizationResponse,
|
||||
getStoredReturnUrl,
|
||||
clearAppleSignInSession,
|
||||
isAppleAuthLoaded,
|
||||
waitForAppleAuth,
|
||||
type AppleAuthorizationResponse
|
||||
} from './utils/appleAuth';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AuthUIConfig,
|
||||
AuthServiceInterface,
|
||||
AuthResult,
|
||||
IconName
|
||||
} from './types';
|
||||
|
||||
// Icon paths
|
||||
export { iconPaths } from './icons/iconPaths';
|
||||
438
packages/shared-auth-ui/src/pages/LoginPage.svelte
Normal file
438
packages/shared-auth-ui/src/pages/LoginPage.svelte
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import type { AuthResult } from '../types';
|
||||
import Icon from '../components/Icon.svelte';
|
||||
import GoogleSignInButton from '../components/GoogleSignInButton.svelte';
|
||||
import AppleSignInButton from '../components/AppleSignInButton.svelte';
|
||||
|
||||
type AuthMode = 'initial' | 'login' | 'forgot-password' | 'password-reset-success';
|
||||
|
||||
interface Props {
|
||||
/** App name */
|
||||
appName: string;
|
||||
/** Logo component */
|
||||
logo: Component<{ size?: number; color?: string }>;
|
||||
/** Primary color (hex) */
|
||||
primaryColor: string;
|
||||
/** Sign in function */
|
||||
onSignIn: (email: string, password: string) => Promise<AuthResult>;
|
||||
/** Sign in with Google function */
|
||||
onSignInWithGoogle?: (idToken: string) => Promise<AuthResult>;
|
||||
/** Sign in with Apple function */
|
||||
onSignInWithApple?: (identityToken: string) => Promise<AuthResult>;
|
||||
/** Forgot password function */
|
||||
onForgotPassword: (email: string) => Promise<AuthResult>;
|
||||
/** Navigation function */
|
||||
goto: (path: string) => void;
|
||||
/** Enable Google Sign-In */
|
||||
enableGoogle?: boolean;
|
||||
/** Enable Apple Sign-In */
|
||||
enableApple?: boolean;
|
||||
/** Success redirect path */
|
||||
successRedirect?: string;
|
||||
/** Register page path */
|
||||
registerPath?: string;
|
||||
/** Light background color */
|
||||
lightBackground?: string;
|
||||
/** Dark background color */
|
||||
darkBackground?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
appName,
|
||||
logo: Logo,
|
||||
primaryColor,
|
||||
onSignIn,
|
||||
onSignInWithGoogle,
|
||||
onSignInWithApple,
|
||||
onForgotPassword,
|
||||
goto,
|
||||
enableGoogle = false,
|
||||
enableApple = false,
|
||||
successRedirect = '/dashboard',
|
||||
registerPath = '/register',
|
||||
lightBackground = '#f5f5f5',
|
||||
darkBackground = '#121212'
|
||||
}: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let mode = $state<AuthMode>('initial');
|
||||
let resetEmail = $state('');
|
||||
let showPassword = $state(false);
|
||||
|
||||
// Check for dark mode
|
||||
let isDark = $state(false);
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const listener = (e: MediaQueryListEvent) => {
|
||||
isDark = e.matches;
|
||||
};
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener);
|
||||
return () => {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function getPageBackground() {
|
||||
return isDark ? darkBackground : lightBackground;
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
if (!email) {
|
||||
error = 'Email is required';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
error = 'Password is required';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await onSignIn(email, password);
|
||||
|
||||
loading = false;
|
||||
|
||||
if (result.success) {
|
||||
goto(successRedirect);
|
||||
} else {
|
||||
error = result.error || 'Sign in failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForgotPassword() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
if (!email) {
|
||||
error = 'Email is required';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await onForgotPassword(email);
|
||||
|
||||
loading = false;
|
||||
|
||||
if (result.success) {
|
||||
resetEmail = email;
|
||||
resetForm();
|
||||
switchMode('password-reset-success');
|
||||
} else {
|
||||
error = result.error || 'Failed to send reset email';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSuccess(idToken: string) {
|
||||
if (!onSignInWithGoogle) return;
|
||||
|
||||
const result = await onSignInWithGoogle(idToken);
|
||||
if (result.success) {
|
||||
goto(successRedirect);
|
||||
} else {
|
||||
error = result.error || 'Google sign in failed';
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
email = '';
|
||||
password = '';
|
||||
error = null;
|
||||
}
|
||||
|
||||
function switchMode(newMode: AuthMode) {
|
||||
mode = newMode;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - {appName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen flex-col justify-between"
|
||||
style="background-color: {getPageBackground()};"
|
||||
>
|
||||
<!-- Top Section - Logo -->
|
||||
<div class="flex flex-col items-center justify-center pt-16 pb-8">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full transition-all mb-4"
|
||||
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
|
||||
? '0 6px 12px rgba(0, 0, 0, 0.4)'
|
||||
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
|
||||
>
|
||||
<Logo size={55} color={primaryColor} />
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
|
||||
{appName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Middle Section - Auth Form -->
|
||||
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
|
||||
<div
|
||||
class="w-full max-w-md rounded-xl p-6"
|
||||
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="mb-6">
|
||||
<h2
|
||||
class="text-center text-xl font-semibold flex items-center justify-center gap-2"
|
||||
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
|
||||
>
|
||||
{#if mode === 'initial'}
|
||||
Mana Login
|
||||
{:else if mode === 'login'}
|
||||
Sign In
|
||||
{:else if mode === 'forgot-password'}
|
||||
Reset Password
|
||||
{:else if mode === 'password-reset-success'}
|
||||
Email Sent
|
||||
{/if}
|
||||
</h2>
|
||||
{#if mode === 'initial'}
|
||||
<p
|
||||
class="mt-3 text-sm text-center"
|
||||
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
|
||||
>
|
||||
Sign in with your Mana account
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3">
|
||||
<p class="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Initial Mode -->
|
||||
{#if mode === 'initial'}
|
||||
<div class="mb-2 flex flex-col gap-3">
|
||||
<button
|
||||
onclick={() => goto(registerPath)}
|
||||
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
|
||||
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="user-plus" size={20} />
|
||||
Create Account
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => switchMode('login')}
|
||||
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
|
||||
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="sign-in" size={20} />
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Login Mode -->
|
||||
{:else if mode === 'login'}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin();
|
||||
}}
|
||||
class="pb-4"
|
||||
>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email"
|
||||
required
|
||||
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
|
||||
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
required
|
||||
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
|
||||
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Icon
|
||||
name={showPassword ? 'eye-off' : 'eye'}
|
||||
size={20}
|
||||
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => switchMode('forgot-password')}
|
||||
class="mb-4 flex h-10 w-full items-center justify-center rounded-xl font-medium transition-all hover:opacity-80 border"
|
||||
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
|
||||
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="sign-in" size={20} />
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Social Login -->
|
||||
{#if enableGoogle || enableApple}
|
||||
<div class="my-4 flex items-center gap-3">
|
||||
<div class="flex-1 border-t" style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"></div>
|
||||
<p class="text-xs" style="color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};">or</p>
|
||||
<div class="flex-1 border-t" style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-col gap-2">
|
||||
{#if enableGoogle && onSignInWithGoogle}
|
||||
<GoogleSignInButton onSuccess={handleGoogleSuccess} />
|
||||
{/if}
|
||||
{#if enableApple}
|
||||
<AppleSignInButton />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
onclick={() => {
|
||||
resetForm();
|
||||
switchMode('initial');
|
||||
}}
|
||||
class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
|
||||
style="color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="arrow-left" size={20} />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Mode -->
|
||||
{:else if mode === 'forgot-password'}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleForgotPassword();
|
||||
}}
|
||||
class="pb-4"
|
||||
>
|
||||
<p
|
||||
class="mb-4 text-sm"
|
||||
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
|
||||
>
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email"
|
||||
required
|
||||
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
|
||||
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
|
||||
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="key" size={20} />
|
||||
{loading ? 'Sending...' : 'Reset Password'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
resetForm();
|
||||
switchMode('login');
|
||||
}}
|
||||
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
|
||||
style="color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="arrow-left" size={20} />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Password Reset Success Mode -->
|
||||
{:else if mode === 'password-reset-success'}
|
||||
<div class="pb-4">
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div
|
||||
class="w-20 h-20 rounded-full flex items-center justify-center mb-6"
|
||||
style="background-color: {primaryColor}30;"
|
||||
>
|
||||
<Icon name="mail-open" size={40} color={primaryColor} />
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-sm text-center px-2"
|
||||
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
|
||||
>
|
||||
We've sent a password reset link to <strong>{resetEmail}</strong>. Please check your
|
||||
inbox.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={() => {
|
||||
resetEmail = '';
|
||||
switchMode('login');
|
||||
}}
|
||||
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
|
||||
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="sign-in" size={20} />
|
||||
Back to Login
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => switchMode('forgot-password')}
|
||||
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
|
||||
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
Resend Email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom padding -->
|
||||
<div class="pb-8"></div>
|
||||
</div>
|
||||
310
packages/shared-auth-ui/src/pages/RegisterPage.svelte
Normal file
310
packages/shared-auth-ui/src/pages/RegisterPage.svelte
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import type { AuthResult } from '../types';
|
||||
import Icon from '../components/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
/** App name */
|
||||
appName: string;
|
||||
/** Logo component */
|
||||
logo: Component<{ size?: number; color?: string }>;
|
||||
/** Primary color (hex) */
|
||||
primaryColor: string;
|
||||
/** Sign up function */
|
||||
onSignUp: (email: string, password: string) => Promise<AuthResult>;
|
||||
/** Navigation function */
|
||||
goto: (path: string) => void;
|
||||
/** Success redirect path */
|
||||
successRedirect?: string;
|
||||
/** Login page path */
|
||||
loginPath?: string;
|
||||
/** Light background color */
|
||||
lightBackground?: string;
|
||||
/** Dark background color */
|
||||
darkBackground?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
appName,
|
||||
logo: Logo,
|
||||
primaryColor,
|
||||
onSignUp,
|
||||
goto,
|
||||
successRedirect = '/dashboard',
|
||||
loginPath = '/login',
|
||||
lightBackground = '#f5f5f5',
|
||||
darkBackground = '#121212'
|
||||
}: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
let needsVerification = $state(false);
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let showPassword = $state(false);
|
||||
let showConfirmPassword = $state(false);
|
||||
|
||||
// Check for dark mode
|
||||
let isDark = $state(false);
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const listener = (e: MediaQueryListEvent) => {
|
||||
isDark = e.matches;
|
||||
};
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener);
|
||||
return () => {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Password validation
|
||||
let passwordRequirements = $derived.by(() => {
|
||||
if (!password) {
|
||||
return {
|
||||
length: false,
|
||||
lowercase: false,
|
||||
uppercase: false,
|
||||
digit: false,
|
||||
special: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
length: password.length >= 8,
|
||||
lowercase: /[a-z]/.test(password),
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
digit: /[0-9]/.test(password),
|
||||
special: /[^a-zA-Z0-9]/.test(password)
|
||||
};
|
||||
});
|
||||
|
||||
function getPageBackground() {
|
||||
return isDark ? darkBackground : lightBackground;
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
loading = true;
|
||||
error = null;
|
||||
success = false;
|
||||
|
||||
// Validation
|
||||
if (!email) {
|
||||
error = 'Email is required';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
error = 'Password is required';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
error = 'Please confirm your password';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
error = 'Password must be at least 8 characters';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check password strength
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasDigit = /[0-9]/.test(password);
|
||||
const hasSymbol = /[^a-zA-Z0-9]/.test(password);
|
||||
|
||||
if (!hasLowercase || !hasUppercase || !hasDigit || !hasSymbol) {
|
||||
error = 'Password must include lowercase, uppercase, number, and special character';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await onSignUp(email, password);
|
||||
|
||||
loading = false;
|
||||
|
||||
if (result.success) {
|
||||
if (result.needsVerification) {
|
||||
needsVerification = true;
|
||||
success = true;
|
||||
password = '';
|
||||
confirmPassword = '';
|
||||
} else {
|
||||
goto(successRedirect);
|
||||
}
|
||||
} else {
|
||||
error = result.error || 'Registration failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Create Account - {appName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen flex-col justify-between"
|
||||
style="background-color: {getPageBackground()};"
|
||||
>
|
||||
<!-- Top Section - Logo -->
|
||||
<div class="flex flex-col items-center justify-center pt-16 pb-8">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full transition-all mb-4"
|
||||
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
|
||||
? '0 6px 12px rgba(0, 0, 0, 0.4)'
|
||||
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
|
||||
>
|
||||
<Logo size={55} color={primaryColor} />
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
|
||||
{appName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Middle Section - Register Form -->
|
||||
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
|
||||
<div
|
||||
class="w-full max-w-md rounded-xl p-6"
|
||||
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
|
||||
>
|
||||
<!-- Title -->
|
||||
<h2
|
||||
class="mb-6 text-center text-xl font-semibold"
|
||||
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
|
||||
>
|
||||
Create Account
|
||||
</h2>
|
||||
|
||||
<!-- Error Messages -->
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3">
|
||||
<p class="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Success Message -->
|
||||
{#if success && needsVerification}
|
||||
<div class="mb-4 rounded-xl bg-green-500/20 border border-green-500/30 p-3">
|
||||
<p class="text-sm text-green-500">
|
||||
Account created! Please check your email to verify your account.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Register Form -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRegister();
|
||||
}}
|
||||
class="pb-4"
|
||||
>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email"
|
||||
required
|
||||
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
|
||||
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
required
|
||||
minlength={8}
|
||||
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
|
||||
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Icon
|
||||
name={showPassword ? 'eye-off' : 'eye'}
|
||||
size={20}
|
||||
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm Password"
|
||||
required
|
||||
minlength={8}
|
||||
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
|
||||
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Icon
|
||||
name={showConfirmPassword ? 'eye-off' : 'eye'}
|
||||
size={20}
|
||||
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Password Requirements -->
|
||||
<p
|
||||
class="mb-4 mt-2 text-xs"
|
||||
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
|
||||
>
|
||||
Password must be at least 8 characters with lowercase, uppercase, number, and special
|
||||
character.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
|
||||
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="user-plus" size={20} />
|
||||
{loading ? 'Creating Account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
onclick={() => goto(loginPath)}
|
||||
class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
|
||||
style="color: {isDark ? '#ffffff' : '#000000'};"
|
||||
>
|
||||
<Icon name="arrow-left" size={20} />
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom padding -->
|
||||
<div class="pb-8"></div>
|
||||
</div>
|
||||
80
packages/shared-auth-ui/src/types.ts
Normal file
80
packages/shared-auth-ui/src/types.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { Component } from 'svelte';
|
||||
|
||||
/**
|
||||
* Configuration for auth UI pages
|
||||
*/
|
||||
export interface AuthUIConfig {
|
||||
/** App name to display */
|
||||
appName: string;
|
||||
|
||||
/** Logo component to render */
|
||||
logo: Component<{ size?: number; color?: string }>;
|
||||
|
||||
/** Primary color (hex) */
|
||||
primaryColor: string;
|
||||
|
||||
/** Primary color for dark mode (optional, defaults to primaryColor) */
|
||||
darkPrimaryColor?: string;
|
||||
|
||||
/** Page background color for light mode */
|
||||
lightBackground?: string;
|
||||
|
||||
/** Page background color for dark mode */
|
||||
darkBackground?: string;
|
||||
|
||||
/** Redirect path after successful login (default: '/dashboard') */
|
||||
successRedirect?: string;
|
||||
|
||||
/** Enable Google Sign-In */
|
||||
enableGoogle?: boolean;
|
||||
|
||||
/** Enable Apple Sign-In */
|
||||
enableApple?: boolean;
|
||||
|
||||
/** Google OAuth Client ID (required if enableGoogle is true) */
|
||||
googleClientId?: string;
|
||||
|
||||
/** Apple OAuth Service ID (required if enableApple is true) */
|
||||
appleClientId?: string;
|
||||
|
||||
/** Apple OAuth Redirect URI */
|
||||
appleRedirectUri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth service interface expected by the UI components
|
||||
*/
|
||||
export interface AuthServiceInterface {
|
||||
signIn(email: string, password: string): Promise<AuthResult>;
|
||||
signUp(email: string, password: string): Promise<AuthResult>;
|
||||
signInWithGoogle?(idToken: string): Promise<AuthResult>;
|
||||
signInWithApple?(identityToken: string): Promise<AuthResult>;
|
||||
forgotPassword(email: string): Promise<AuthResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from auth operations
|
||||
*/
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
needsVerification?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon names available in the icon set
|
||||
*/
|
||||
export type IconName =
|
||||
| 'user-plus'
|
||||
| 'sign-in'
|
||||
| 'eye'
|
||||
| 'eye-off'
|
||||
| 'key'
|
||||
| 'arrow-left'
|
||||
| 'info'
|
||||
| 'mail-open'
|
||||
| 'lock'
|
||||
| 'shield-check'
|
||||
| 'arrows-left-right'
|
||||
| 'envelope'
|
||||
| 'folder';
|
||||
216
packages/shared-auth-ui/src/utils/appleAuth.ts
Normal file
216
packages/shared-auth-ui/src/utils/appleAuth.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Apple Sign-In integration for web
|
||||
* Uses redirect flow (not popup)
|
||||
*/
|
||||
|
||||
// TypeScript definitions for Apple ID SDK
|
||||
declare global {
|
||||
interface Window {
|
||||
AppleID?: {
|
||||
auth: {
|
||||
init: (config: AppleIDInitConfig) => void;
|
||||
signIn: () => Promise<AppleIDSignInResponse>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface AppleIDInitConfig {
|
||||
clientId: string;
|
||||
scope: string;
|
||||
redirectURI: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
usePopup?: boolean;
|
||||
responseType?: string;
|
||||
responseMode?: string;
|
||||
}
|
||||
|
||||
interface AppleIDSignInResponse {
|
||||
authorization: {
|
||||
code: string;
|
||||
id_token?: string;
|
||||
state?: string;
|
||||
};
|
||||
user?: {
|
||||
email?: string;
|
||||
name?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppleAuthorizationResponse {
|
||||
code: string;
|
||||
id_token?: string;
|
||||
state?: string;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
let appleClientId: string | null = null;
|
||||
let appleRedirectUri: string | null = null;
|
||||
|
||||
/**
|
||||
* Set Apple Sign-In configuration
|
||||
*/
|
||||
export function setAppleConfig(clientId: string, redirectUri: string) {
|
||||
appleClientId = clientId;
|
||||
appleRedirectUri = redirectUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in browser
|
||||
*/
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Apple ID SDK
|
||||
*/
|
||||
export function initializeAppleAuth(): boolean {
|
||||
if (!isBrowser() || !window.AppleID) {
|
||||
console.warn('Apple ID SDK not loaded');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!appleClientId || !appleRedirectUri) {
|
||||
console.error('Apple Sign-In not configured. Call setAppleConfig() first.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
window.AppleID.auth.init({
|
||||
clientId: appleClientId,
|
||||
scope: 'name email',
|
||||
redirectURI: appleRedirectUri,
|
||||
state: generateState(),
|
||||
usePopup: false,
|
||||
responseType: 'code id_token',
|
||||
responseMode: 'form_post'
|
||||
});
|
||||
|
||||
console.log('Apple ID SDK initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing Apple ID SDK:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate Apple Sign-In (redirect flow)
|
||||
*/
|
||||
export async function signInWithApple(): Promise<void> {
|
||||
if (!isBrowser()) {
|
||||
throw new Error('Apple Sign-In only available in browser');
|
||||
}
|
||||
|
||||
if (!window.AppleID) {
|
||||
throw new Error('Apple ID SDK not loaded');
|
||||
}
|
||||
|
||||
try {
|
||||
const returnTo = window.location.pathname + window.location.search;
|
||||
sessionStorage.setItem('apple_signin_return_to', returnTo);
|
||||
await window.AppleID.auth.signIn();
|
||||
} catch (error) {
|
||||
console.error('Error initiating Apple Sign-In:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Apple authorization response from URL
|
||||
*/
|
||||
export function parseAppleAuthorizationResponse(
|
||||
urlParams: URLSearchParams
|
||||
): AppleAuthorizationResponse | null {
|
||||
const code = urlParams.get('code');
|
||||
const id_token = urlParams.get('id_token');
|
||||
const state = urlParams.get('state');
|
||||
const user = urlParams.get('user');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
console.error('Apple Sign-In error:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedState = sessionStorage.getItem('apple_signin_state');
|
||||
if (state !== storedState) {
|
||||
console.error('State mismatch - possible CSRF attack');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!id_token && !code) {
|
||||
console.error('No id_token or authorization code in Apple response');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: code || '',
|
||||
id_token: id_token || undefined,
|
||||
state: state || undefined,
|
||||
user: user || undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random state for CSRF protection
|
||||
*/
|
||||
function generateState(): string {
|
||||
const state = Math.random().toString(36).substring(2, 15);
|
||||
if (isBrowser()) {
|
||||
sessionStorage.setItem('apple_signin_state', state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored return URL
|
||||
*/
|
||||
export function getStoredReturnUrl(): string {
|
||||
if (!isBrowser()) return '/dashboard';
|
||||
return sessionStorage.getItem('apple_signin_return_to') || '/dashboard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Apple Sign-In session data
|
||||
*/
|
||||
export function clearAppleSignInSession() {
|
||||
if (!isBrowser()) return;
|
||||
sessionStorage.removeItem('apple_signin_state');
|
||||
sessionStorage.removeItem('apple_signin_return_to');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Apple ID SDK is loaded
|
||||
*/
|
||||
export function isAppleAuthLoaded(): boolean {
|
||||
return isBrowser() && !!window.AppleID?.auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Apple ID SDK to load
|
||||
*/
|
||||
export function waitForAppleAuth(timeout = 10000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isAppleAuthLoaded()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
if (isAppleAuthLoaded()) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Apple ID SDK failed to load'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
174
packages/shared-auth-ui/src/utils/googleAuth.ts
Normal file
174
packages/shared-auth-ui/src/utils/googleAuth.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Google Identity Services integration
|
||||
* Provides helper functions for Google Sign-In on web
|
||||
*/
|
||||
|
||||
// TypeScript definitions for Google Identity Services
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: GoogleIdConfiguration) => void;
|
||||
prompt: (momentListener?: (notification: PromptMomentNotification) => void) => void;
|
||||
renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void;
|
||||
disableAutoSelect: () => void;
|
||||
storeCredential: (credential: { id: string; password: string }) => void;
|
||||
cancel: () => void;
|
||||
onGoogleLibraryLoad: () => void;
|
||||
revoke: (hint: string, callback: (done: RevocationResponse) => void) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GoogleIdConfiguration {
|
||||
client_id: string;
|
||||
callback: (response: CredentialResponse) => void;
|
||||
auto_select?: boolean;
|
||||
cancel_on_tap_outside?: boolean;
|
||||
context?: 'signin' | 'signup' | 'use';
|
||||
ux_mode?: 'popup' | 'redirect';
|
||||
login_uri?: string;
|
||||
native_callback?: (response: { id: string; password: string }) => void;
|
||||
itp_support?: boolean;
|
||||
}
|
||||
|
||||
interface CredentialResponse {
|
||||
credential: string;
|
||||
select_by: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
interface GsiButtonConfiguration {
|
||||
type?: 'standard' | 'icon';
|
||||
theme?: 'outline' | 'filled_blue' | 'filled_black';
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
||||
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
|
||||
logo_alignment?: 'left' | 'center';
|
||||
width?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
interface PromptMomentNotification {
|
||||
isDisplayMoment: () => boolean;
|
||||
isDisplayed: () => boolean;
|
||||
isNotDisplayed: () => boolean;
|
||||
getNotDisplayedReason: () => string;
|
||||
isSkippedMoment: () => boolean;
|
||||
getSkippedReason: () => string;
|
||||
isDismissedMoment: () => boolean;
|
||||
getDismissedReason: () => string;
|
||||
getMomentType: () => 'display' | 'skipped' | 'dismissed';
|
||||
}
|
||||
|
||||
interface RevocationResponse {
|
||||
successful: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let googleClientId: string | null = null;
|
||||
|
||||
/**
|
||||
* Set Google Client ID for initialization
|
||||
*/
|
||||
export function setGoogleClientId(clientId: string) {
|
||||
googleClientId = clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Google Identity Services
|
||||
*/
|
||||
export function initializeGoogleAuth(callback: (idToken: string) => void) {
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('Google Auth: Cannot initialize on server-side');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.google) {
|
||||
console.warn('Google Identity Services not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!googleClientId) {
|
||||
console.error('Google Client ID not configured. Call setGoogleClientId() first.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: googleClientId,
|
||||
callback: (response: CredentialResponse) => {
|
||||
callback(response.credential);
|
||||
},
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
ux_mode: 'popup'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing Google Auth:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Google Sign-In button
|
||||
*/
|
||||
export function renderGoogleButton(
|
||||
element: HTMLElement,
|
||||
options?: Partial<GsiButtonConfiguration>
|
||||
) {
|
||||
if (typeof window === 'undefined' || !window.google) {
|
||||
console.warn('Google Identity Services not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultOptions: GsiButtonConfiguration = {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'signin_with',
|
||||
shape: 'rectangular',
|
||||
logo_alignment: 'left'
|
||||
};
|
||||
|
||||
try {
|
||||
window.google.accounts.id.renderButton(element, {
|
||||
...defaultOptions,
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rendering Google button:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Google Identity Services is loaded
|
||||
*/
|
||||
export function isGoogleAuthLoaded(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.google?.accounts?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Google Identity Services to load
|
||||
*/
|
||||
export function waitForGoogleAuth(timeout = 10000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isGoogleAuthLoaded()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
if (isGoogleAuthLoaded()) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Google Identity Services failed to load'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue