From 434aeb275a807b8c7ec7278f0ebdb22f6162f46b Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 10 Oct 2025 10:26:06 +0200 Subject: [PATCH] feat(login): comprehensive theme system (#10848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved This PR introduces a comprehensive theme customization system for the login application with responsive behavior and enhanced visual options. Screenshot 2025-08-19 at 09 55 24 # How the Problems Are Solved ## ✨ Features Added - **šŸ”„ Responsive Layout System**: Automatic switching between side-by-side and top-to-bottom layouts based on screen size - **šŸ–¼ļø Background Image Support**: Custom background images configurable via environment variables - **āš™ļø Theme Configuration**: Complete theme system with roundness, spacing, appearance, and layout options - **šŸ“± Mobile-First Design**: Intelligent layout adaptation for different screen sizes - **šŸŽÆ Enhanced Typography**: Improved visual hierarchy with larger titles in side-by-side mode ## šŸ—ļø Architecture - **Server-Safe Theme Functions**: Theme configuration accessible on both server and client - **SSR-Safe Hooks**: Proper hydration handling for responsive layouts - **Component Separation**: Clear boundaries between server and client components - **Two-Section Layout**: Consistent content structure across all login pages ## šŸ”§ Configuration Options All theme options are configurable via environment variables: - `NEXT_PUBLIC_THEME_ROUNDNESS`: `edgy` | `mid` | `full` - `NEXT_PUBLIC_THEME_LAYOUT`: `side-by-side` | `top-to-bottom` - `NEXT_PUBLIC_THEME_APPEARANCE`: `flat` | `material` - `NEXT_PUBLIC_THEME_SPACING`: `regular` | `compact` - `NEXT_PUBLIC_THEME_BACKGROUND_IMAGE`: Custom background image URL ## šŸ“„ Pages Updated Updated all major login pages to use the new two-section responsive layout: - Login name entry - Password verification - MFA verification - User registration - Account selection - Device authorization - Logout confirmation ## šŸ“š Documentation - **THEME_ARCHITECTURE.md**: Complete technical documentation of the theme system - **THEME_CUSTOMIZATION.md**: User-friendly guide with examples and troubleshooting ## šŸš€ Benefits - **Better UX**: Responsive design that works seamlessly across all devices - **Brand Flexibility**: Easy customization to match any brand identity - **Maintainable Code**: Clean separation of concerns and well-documented architecture - **Future-Proof**: Extensible system for additional theme options Screenshot 2025-08-19 at 09 22 23 Screenshot 2025-08-19 at 09 23 45 Screenshot 2025-08-19 at 09 23 17 --- apps/login/.env.theme.example | 75 +++ apps/login/THEME_ARCHITECTURE.md | 212 ++++++++ apps/login/THEME_CUSTOMIZATION.md | 138 ++++++ apps/login/next-env-vars.d.ts | 10 + apps/login/src/app/(login)/accounts/page.tsx | 18 +- .../app/(login)/authenticator/set/page.tsx | 33 +- .../src/app/(login)/device/consent/page.tsx | 20 +- apps/login/src/app/(login)/device/page.tsx | 11 +- .../(login)/idp/[provider]/failure/page.tsx | 12 +- .../(login)/idp/[provider]/success/page.tsx | 5 +- apps/login/src/app/(login)/idp/ldap/page.tsx | 9 +- apps/login/src/app/(login)/idp/page.tsx | 11 +- apps/login/src/app/(login)/layout.tsx | 21 +- apps/login/src/app/(login)/loginname/page.tsx | 17 +- .../src/app/(login)/logout/done/page.tsx | 3 +- apps/login/src/app/(login)/logout/page.tsx | 6 +- apps/login/src/app/(login)/mfa/page.tsx | 29 +- apps/login/src/app/(login)/mfa/set/page.tsx | 62 +-- .../src/app/(login)/otp/[method]/page.tsx | 4 +- .../src/app/(login)/otp/[method]/set/page.tsx | 94 ++-- apps/login/src/app/(login)/passkey/page.tsx | 28 +- .../src/app/(login)/passkey/set/page.tsx | 9 +- .../src/app/(login)/password/change/page.tsx | 41 +- apps/login/src/app/(login)/password/page.tsx | 43 +- .../src/app/(login)/password/set/page.tsx | 30 +- apps/login/src/app/(login)/register/page.tsx | 7 +- .../app/(login)/register/password/page.tsx | 12 +- apps/login/src/app/(login)/signedin/page.tsx | 41 +- apps/login/src/app/(login)/u2f/page.tsx | 11 +- apps/login/src/app/(login)/u2f/set/page.tsx | 16 +- apps/login/src/app/(login)/verify/page.tsx | 32 +- .../src/app/(login)/verify/success/page.tsx | 9 +- apps/login/src/components/LoginLayout.tsx | 0 apps/login/src/components/avatar.test.tsx | 168 +++++++ apps/login/src/components/avatar.tsx | 11 +- apps/login/src/components/back-button.tsx | 6 +- .../src/components/background-wrapper.tsx | 27 ++ apps/login/src/components/button.test.tsx | 215 +++++++++ apps/login/src/components/button.tsx | 84 ++-- apps/login/src/components/card.test.tsx | 175 +++++++ apps/login/src/components/card.tsx | 72 +++ apps/login/src/components/dynamic-theme.tsx | 139 +++++- .../login/src/components/idps/base-button.tsx | 24 +- .../components/idps/pages/complete-idp.tsx | 4 +- .../components/idps/pages/linking-failed.tsx | 14 +- .../components/idps/pages/linking-success.tsx | 10 +- .../components/idps/pages/login-failed.tsx | 7 +- .../components/idps/pages/login-success.tsx | 8 +- apps/login/src/components/input.test.tsx | 188 ++++++++ apps/login/src/components/input.tsx | 63 +-- .../src/components/language-switcher.tsx | 42 +- .../login/src/components/sign-in-with-idp.tsx | 32 +- apps/login/src/components/skeleton-card.tsx | 65 ++- apps/login/src/components/theme-switch.tsx | 69 +++ apps/login/src/components/theme-wrapper.tsx | 27 ++ apps/login/src/components/theme.tsx | 44 -- apps/login/src/components/user-avatar.tsx | 28 +- apps/login/src/lib/session.test.ts | 7 +- apps/login/src/lib/theme-hooks.ts | 62 +++ apps/login/src/lib/theme.test.ts | 453 ++++++++++++++++++ apps/login/src/lib/theme.ts | 154 ++++++ apps/login/src/lib/themeUtils.tsx | 49 ++ apps/login/src/lib/zitadel.ts | 8 +- apps/login/src/styles/globals.scss | 18 +- apps/login/test-theme.js | 56 +++ 65 files changed, 2782 insertions(+), 616 deletions(-) create mode 100644 apps/login/.env.theme.example create mode 100644 apps/login/THEME_ARCHITECTURE.md create mode 100644 apps/login/THEME_CUSTOMIZATION.md create mode 100644 apps/login/src/components/LoginLayout.tsx create mode 100644 apps/login/src/components/avatar.test.tsx create mode 100644 apps/login/src/components/background-wrapper.tsx create mode 100644 apps/login/src/components/button.test.tsx create mode 100644 apps/login/src/components/card.test.tsx create mode 100644 apps/login/src/components/card.tsx create mode 100644 apps/login/src/components/input.test.tsx create mode 100644 apps/login/src/components/theme-switch.tsx delete mode 100644 apps/login/src/components/theme.tsx create mode 100644 apps/login/src/lib/theme-hooks.ts create mode 100644 apps/login/src/lib/theme.test.ts create mode 100644 apps/login/src/lib/theme.ts create mode 100644 apps/login/src/lib/themeUtils.tsx create mode 100644 apps/login/test-theme.js diff --git a/apps/login/.env.theme.example b/apps/login/.env.theme.example new file mode 100644 index 00000000000..ab5dd7ffda6 --- /dev/null +++ b/apps/login/.env.theme.example @@ -0,0 +1,75 @@ +# ============================================== +# ZITADEL LOGIN THEME CONFIGURATION +# ============================================== +# This file contains all available theme options for customizing the login experience. +# Copy the variables you want to customize to your .env.local file. + +# Theme Roundness Options +# ---------------------- +# Controls the border radius of UI elements +# Options: "edgy" | "mid" | "full" +# - edgy: Sharp corners (rounded-none) +# - mid: Medium rounded corners (rounded-lg) +# - full: Fully rounded corners (rounded-full) +NEXT_PUBLIC_THEME_ROUNDNESS=mid + +# Layout Options +# -------------- +# Controls the overall layout structure +# Options: "side-by-side" | "top-to-bottom" +# - side-by-side: Brand section on left, form on right (desktop) +# - top-to-bottom: Brand section on top, form below +NEXT_PUBLIC_THEME_LAYOUT=side-by-side + +# Spacing Options +# --------------- +# Controls spacing and padding throughout the interface +# Options: "regular" | "compact" +# - regular: Standard spacing with comfortable padding (p-6, space-y-6) +# - compact: Tighter spacing for information-dense layouts (p-4, space-y-4) +NEXT_PUBLIC_THEME_SPACING=regular + +# Appearance Options +# ------------------ +# Complete design philosophies +# Options: "flat" | "material" +# - flat: Minimal flat design with cards matching background color, subtle borders, and normal typography +# - material: Material Design inspired with elevated cards, proper contrast, and medium typography +NEXT_PUBLIC_THEME_APPEARANCE=flat + +# Background Image +# --------------- +# Path to a background image (optional) +# Can be a relative path from /public or an absolute URL +# Example: NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/login-bg.jpg +# NEXT_PUBLIC_THEME_BACKGROUND_IMAGE= + +# ============================================== +# EXAMPLE COMBINATIONS +# ============================================== + +# Modern Tech Startup +# NEXT_PUBLIC_THEME_ROUNDNESS=full +# NEXT_PUBLIC_THEME_LAYOUT=side-by-side +# NEXT_PUBLIC_THEME_SPACING=regular +# NEXT_PUBLIC_THEME_APPEARANCE=material +# NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/tech-bg.jpg + +# Corporate Banking +# NEXT_PUBLIC_THEME_ROUNDNESS=edgy +# NEXT_PUBLIC_THEME_LAYOUT=top-to-bottom +# NEXT_PUBLIC_THEME_SPACING=regular +# NEXT_PUBLIC_THEME_APPEARANCE=material + +# Minimal SaaS +# NEXT_PUBLIC_THEME_ROUNDNESS=mid +# NEXT_PUBLIC_THEME_LAYOUT=side-by-side +# NEXT_PUBLIC_THEME_SPACING=compact +# NEXT_PUBLIC_THEME_APPEARANCE=flat + +# Creative Agency +# NEXT_PUBLIC_THEME_ROUNDNESS=full +# NEXT_PUBLIC_THEME_LAYOUT=top-to-bottom +# NEXT_PUBLIC_THEME_SPACING=regular +# NEXT_PUBLIC_THEME_APPEARANCE=material +# NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/creative-bg.jpg diff --git a/apps/login/THEME_ARCHITECTURE.md b/apps/login/THEME_ARCHITECTURE.md new file mode 100644 index 00000000000..10684becdb6 --- /dev/null +++ b/apps/login/THEME_ARCHITECTURE.md @@ -0,0 +1,212 @@ +# Current Theme System Architecture + +Our theme system provides a simple, environment variable-driven approach for consistent component styling and responsive layout switching. + +## šŸ—ļø **Current Implementation** + +### **Environment Variable Configuration** + +```bash +# .env.local +NEXT_PUBLIC_THEME_ROUNDNESS=mid # edgy | mid | full +NEXT_PUBLIC_THEME_LAYOUT=side-by-side # side-by-side | top-to-bottom +NEXT_PUBLIC_THEME_APPEARANCE=material # flat | material +NEXT_PUBLIC_THEME_SPACING=regular # regular | compact +``` + +### **Core Theme Functions** + +```tsx +// Server-safe theme configuration +import { getThemeConfig, getComponentRoundness } from "@/lib/theme"; + +// Get full theme configuration +const themeConfig = getThemeConfig(); +// Returns: { roundness: 'mid', layout: 'side-by-side', appearance: 'material', ... } + +// Get component-specific styling +const buttonRoundness = getComponentRoundness("button"); +// Returns: "rounded-md" (CSS class) +``` + +### **Responsive Layout Hook** + +```tsx +// Client-side responsive layout detection +import { useResponsiveLayout } from "@/lib/theme-hooks"; + +function MyComponent() { + const { isSideBySide, isResponsiveOverride } = useResponsiveLayout(); + + return
{/* Layout adapts automatically */}
; +} +``` + +## šŸŽØ **Component Integration Patterns** + +### **Pattern 1: Direct Function Calls** (Current Standard) + +```tsx +import { getComponentRoundness } from "@/lib/theme"; + +export function Button({ children, variant = "primary" }) { + const roundness = getComponentRoundness("button"); + + return ( + + ); +} +``` + +### **Pattern 2: Component-Specific Helper Functions** + +```tsx +import { getComponentRoundness } from "@/lib/theme"; + +// Helper function for UserAvatar +function getUserAvatarRoundness(): string { + return getComponentRoundness("avatarContainer"); +} + +export function UserAvatar({ loginName, displayName }) { + const roundness = getUserAvatarRoundness(); + + return
{/* Avatar content */}
; +} +``` + +### **Pattern 3: Theme-Aware Layout Components** + +```tsx +import { useResponsiveLayout } from "@/lib/theme-hooks"; + +export function DynamicTheme({ children, branding }) { + const { isSideBySide } = useResponsiveLayout(); + + return ( + + {isSideBySide ? ( + // Side-by-side layout for desktop +
+
{/* Left content */}
+
{/* Right content */}
+
+ ) : ( + // Top-to-bottom layout for mobile +
{children}
+ )} +
+ ); +} +``` + +## šŸŽÆ **Theme Configuration Structure** + +### **Component Roundness Mapping** + +```tsx +export interface ComponentRoundnessConfig { + card: ThemeRoundness; // "rounded-lg" | "rounded-none" | "rounded-3xl" + button: ThemeRoundness; // "rounded-md" | "rounded-none" | "rounded-full" + input: ThemeRoundness; // "rounded-md" | "rounded-none" | "rounded-full pl-4" + image: ThemeRoundness; // "rounded-lg" | "rounded-none" | "rounded-full" + avatar: ThemeRoundness; // "rounded-lg" | "rounded-none" | "rounded-full" + avatarContainer: ThemeRoundness; // "rounded-md" | "rounded-none" | "rounded-full" + themeSwitch: ThemeRoundness; // "rounded-md" | "rounded-none" | "rounded-full" +} +``` + +### **Responsive Layout Logic** + +```tsx +// Automatic layout switching based on screen size +const isSideBySide = themeConfig.layout === "side-by-side" && !isMdOrSmaller; + +// md breakpoint: 768px (Tailwind default) +// Below 768px: Always use top-to-bottom layout +// Above 768px: Use configured layout (side-by-side or top-to-bottom) +``` + +## ļæ½ **File Structure** + +``` +src/lib/ +ā”œā”€ā”€ theme.ts # Server-safe theme functions +ā”œā”€ā”€ theme-hooks.ts # Client-side responsive hooks +└── themeUtils.tsx # Legacy utility functions + +src/components/ +ā”œā”€ā”€ dynamic-theme.tsx # Main responsive layout component +ā”œā”€ā”€ theme-wrapper.tsx # Theme application wrapper +ā”œā”€ā”€ button.tsx # Example themed component +ā”œā”€ā”€ card.tsx # Example themed component +└── user-avatar.tsx # Example themed component +``` + +## ļæ½ **Usage Examples** + +### **Adding Theme Support to New Components** + +```tsx +import { getComponentRoundness } from "@/lib/theme"; + +export function NewComponent() { + // Get theme-appropriate styling + const roundness = getComponentRoundness("card"); + + return
{/* Component content */}
; +} +``` + +### **Using Responsive Layout** + +```tsx +import { useResponsiveLayout } from "@/lib/theme-hooks"; + +export function ResponsiveComponent() { + const { isSideBySide } = useResponsiveLayout(); + + return
Content adapts to layout
; +} +``` + +### **Page Layout Integration** + +```tsx +import { DynamicTheme } from "@/components/dynamic-theme"; + +export default function LoginPage() { + return ( + +
+

Login Title

+

Description text

+
+ +
+ +
+
+ ); +} +``` + +## ⚔ **Key Features** + +1. **Environment Variable Configuration**: Simple `.env.local` setup +2. **Server-Safe Functions**: Work in both SSR and client components +3. **Responsive Layout Switching**: Automatic mobile/desktop adaptation +4. **Component-Specific Styling**: Different roundness per component type +5. **Type Safety**: Full TypeScript support +6. **Zero Runtime Dependencies**: No context providers or complex state +7. **SSR Compatible**: No hydration mismatches + +## šŸ”„ **Architecture Benefits** + +- **Simple**: Environment variables → CSS classes +- **Fast**: No runtime theme calculations or context switching +- **Reliable**: Server-side rendering compatible +- **Scalable**: Easy to add new theme properties +- **Maintainable**: Clear separation between layout and styling concerns + +This architecture provides a solid foundation for environment-driven theming while keeping the implementation simple and performant! diff --git a/apps/login/THEME_CUSTOMIZATION.md b/apps/login/THEME_CUSTOMIZATION.md new file mode 100644 index 00000000000..319131ca5b0 --- /dev/null +++ b/apps/login/THEME_CUSTOMIZATION.md @@ -0,0 +1,138 @@ +# Login App Theme Customization Guide + +This guide helps you customize the appearance of your ZITADEL login application for a personalized user experience. + +## Quick Start + +1. Copy the theme variables you want to customize from `.env.theme.example` to your `.env.local` file +2. Restart your application +3. Your theme changes will be applied automatically + +## Theme Options + +### šŸ”„ Roundness (`NEXT_PUBLIC_THEME_ROUNDNESS`) + +Controls how rounded the UI elements appear: + +- **`edgy`** - Sharp, rectangular corners (modern tech, corporate) +- **`mid`** - Medium rounded corners (balanced, professional) +- **`full`** - Fully rounded corners (friendly, approachable) + +### šŸ“± Layout (`NEXT_PUBLIC_THEME_LAYOUT`) + +Controls the overall page structure: + +- **`side-by-side`** - Brand section on left, form on right (desktop view) +- **`top-to-bottom`** - Brand section on top, form below (mobile-first) + +### šŸ“ Spacing (`NEXT_PUBLIC_THEME_SPACING`) + +Controls spacing and padding throughout the interface: + +- **`regular`** - Standard spacing with comfortable padding (p-6, space-y-6) +- **`compact`** - Tighter spacing for information-dense layouts (p-4, space-y-4) + +### šŸŽØ Appearance (`NEXT_PUBLIC_THEME_APPEARANCE`) + +Complete design philosophies: + +- **`flat`** - Minimal flat design with cards matching background color, subtle borders, and normal typography +- **`material`** - Material Design inspired with elevated cards, proper contrast, and medium typography + +### šŸ–¼ļø Background Image (`NEXT_PUBLIC_THEME_BACKGROUND_IMAGE`) + +Add a custom background image: + +- Use local images: `/images/my-background.jpg` (place in `public/images/`) +- Use external URLs: `https://example.com/background.jpg` +- Leave empty for solid color backgrounds + +## Example Configurations + +### Tech Startup + +```env +NEXT_PUBLIC_THEME_ROUNDNESS=full +NEXT_PUBLIC_THEME_LAYOUT=side-by-side +NEXT_PUBLIC_THEME_SPACING=regular +NEXT_PUBLIC_THEME_APPEARANCE=material +NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/tech-gradient.jpg +``` + +### Corporate Bank + +```env +NEXT_PUBLIC_THEME_ROUNDNESS=edgy +NEXT_PUBLIC_THEME_LAYOUT=top-to-bottom +NEXT_PUBLIC_THEME_SPACING=regular +NEXT_PUBLIC_THEME_APPEARANCE=material +``` + +### Minimal SaaS + +```env +NEXT_PUBLIC_THEME_ROUNDNESS=mid +NEXT_PUBLIC_THEME_LAYOUT=side-by-side +NEXT_PUBLIC_THEME_SPACING=compact +NEXT_PUBLIC_THEME_APPEARANCE=flat +``` + +### Creative Agency + +```env +NEXT_PUBLIC_THEME_ROUNDNESS=full +NEXT_PUBLIC_THEME_LAYOUT=top-to-bottom +NEXT_PUBLIC_THEME_SPACING=regular +NEXT_PUBLIC_THEME_APPEARANCE=material +NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/creative-workspace.jpg +``` + +### Flat Design App + +```env +NEXT_PUBLIC_THEME_ROUNDNESS=mid +NEXT_PUBLIC_THEME_LAYOUT=side-by-side +NEXT_PUBLIC_THEME_SPACING=compact +NEXT_PUBLIC_THEME_APPEARANCE=flat +``` + +### Material Design System + +```env +NEXT_PUBLIC_THEME_ROUNDNESS=mid +NEXT_PUBLIC_THEME_LAYOUT=side-by-side +NEXT_PUBLIC_THEME_SPACING=regular +NEXT_PUBLIC_THEME_APPEARANCE=material +``` + +## Advanced Customization + +For more detailed customization beyond these presets, you can: + +1. **Custom CSS**: Add your own CSS files in the `src/styles/` directory +2. **Component Override**: Modify the theme configuration in `src/lib/theme.ts` +3. **Custom Appearances**: Add new appearance options to the `APPEARANCE_STYLES` or `SPACING_STYLES` objects + +## Troubleshooting + +### Theme not applying + +- Ensure you're using `NEXT_PUBLIC_` prefix for all theme variables (`NEXT_PUBLIC_THEME_ROUNDNESS`, `NEXT_PUBLIC_THEME_SPACING`, `NEXT_PUBLIC_THEME_APPEARANCE`, etc.) +- Restart your development server after changing environment variables +- Check that your `.env.local` file is in the root of the login app directory + +### Background image not loading + +- Verify the image path is correct +- For external URLs, ensure the domain is accessible +- Check browser console for any loading errors + +### Layout issues on mobile + +- Test your theme on different screen sizes +- The `top-to-bottom` layout is generally more mobile-friendly +- Some combinations work better on certain screen sizes + +## Support + +For additional customization needs or questions, please refer to the ZITADEL documentation or community forums. diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index 1d8467a6726..833239f132e 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -34,5 +34,15 @@ declare namespace NodeJS { * `CUSTOM_REQUEST_HEADERS=Host:http://zitadel-internal:8080` */ CUSTOM_REQUEST_HEADERS?: string; + + /** + * The base path the app is served from, e.g. /ui/v2/login + */ + NEXT_PUBLIC_BASE_PATH: string; + + /** + * Optional: The application name shown in the login and invite emails + */ + NEXT_PUBLIC_APPLICATION_NAME?: string; } } diff --git a/apps/login/src/app/(login)/accounts/page.tsx b/apps/login/src/app/(login)/accounts/page.tsx index 50407e99645..354d36f01e2 100644 --- a/apps/login/src/app/(login)/accounts/page.tsx +++ b/apps/login/src/app/(login)/accounts/page.tsx @@ -3,11 +3,7 @@ import { SessionsList } from "@/components/sessions-list"; import { Translated } from "@/components/translated"; import { getAllSessionCookieIds } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { - getBrandingSettings, - getDefaultOrg, - listSessions, -} from "@/lib/zitadel"; +import { getBrandingSettings, getDefaultOrg, listSessions } from "@/lib/zitadel"; import { UserPlusIcon } from "@heroicons/react/24/outline"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Metadata } from "next"; @@ -18,7 +14,7 @@ import Link from "next/link"; export async function generateMetadata(): Promise { const t = await getTranslations("accounts"); - return { title: t('title')}; + return { title: t("title") }; } async function loadSessions({ serviceUrl }: { serviceUrl: string }) { @@ -36,9 +32,7 @@ async function loadSessions({ serviceUrl }: { serviceUrl: string }) { } } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const requestId = searchParams?.requestId; @@ -76,14 +70,16 @@ export default async function Page(props: { return ( -
+

-

+

+
+
diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index 1f8912d95c2..c52d408eb5c 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -26,12 +26,10 @@ import { redirect } from "next/navigation"; export async function generateMetadata(): Promise { const t = await getTranslations("authenticator"); - return { title: t('title')}; + return { title: t("title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const { loginName, requestId, organization, sessionId } = searchParams; @@ -59,8 +57,7 @@ export default async function Page(props: { userId, }).then((methods) => { return getUserByID({ serviceUrl, userId }).then((user) => { - const humanUser = - user.user?.type.case === "human" ? user.user?.type.value : undefined; + const humanUser = user.user?.type.case === "human" ? user.user?.type.value : undefined; return { factors: session?.factors, @@ -73,10 +70,7 @@ export default async function Page(props: { }); } - async function loadSessionByLoginname( - loginName?: string, - organization?: string, - ) { + async function loadSessionByLoginname(loginName?: string, organization?: string) { return loadMostRecentSession({ serviceUrl, sessionParams: { @@ -99,11 +93,7 @@ export default async function Page(props: { }); } - if ( - !sessionWithData || - !sessionWithData.factors || - !sessionWithData.factors.user - ) { + if (!sessionWithData || !sessionWithData.factors || !sessionWithData.factors.user) { return ( @@ -122,9 +112,7 @@ export default async function Page(props: { }); // check if user was verified recently - const isUserVerified = await checkUserVerification( - sessionWithData.factors.user?.id, - ); + const isUserVerified = await checkUserVerification(sessionWithData.factors.user?.id); if (!isUserVerified) { const params = new URLSearchParams({ @@ -138,10 +126,7 @@ export default async function Page(props: { } if (organization || sessionWithData.factors.user.organizationId) { - params.append( - "organization", - organization ?? (sessionWithData.factors.user.organizationId as string), - ); + params.append("organization", organization ?? (sessionWithData.factors.user.organizationId as string)); } redirect(`/verify?` + params); @@ -173,7 +158,7 @@ export default async function Page(props: { return ( -
+

@@ -188,7 +173,9 @@ export default async function Page(props: { showDropdown searchParams={searchParams} > +
+
{loginSettings && ( >; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const userCode = searchParams?.user_code; @@ -70,13 +64,9 @@ export default async function Page(props: { return ( -
+

- +

@@ -86,7 +76,9 @@ export default async function Page(props: { data={{ appName: deviceAuthorizationRequest?.appName }} />

+
+
{ const t = await getTranslations("device"); - return { title: t('usercode.title')}; + return { title: t("usercode.title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const userCode = searchParams?.user_code; @@ -41,13 +39,16 @@ export default async function Page(props: { return ( -
+

+
+ +
diff --git a/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx b/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx index f2b7a19b913..2eb3a418615 100644 --- a/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx @@ -4,12 +4,7 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { - getBrandingSettings, - getLoginSettings, - getUserByID, - listAuthenticationMethodTypes, -} from "@/lib/zitadel"; +import { getBrandingSettings, getLoginSettings, getUserByID, listAuthenticationMethodTypes } from "@/lib/zitadel"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { headers } from "next/headers"; @@ -74,10 +69,13 @@ export default async function Page(props: { return ( -
+

+
+ +
diff --git a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx index 1acf2a39c6f..a19a7f63419 100644 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -293,13 +293,16 @@ export default async function Page(props: { if (newUser) { return ( -
+

+
+ +
diff --git a/apps/login/src/app/(login)/idp/ldap/page.tsx b/apps/login/src/app/(login)/idp/ldap/page.tsx index 372c814525a..3fdde64d9fa 100644 --- a/apps/login/src/app/(login)/idp/ldap/page.tsx +++ b/apps/login/src/app/(login)/idp/ldap/page.tsx @@ -38,18 +38,17 @@ export default async function Page(props: { // return login failed if no linking or creation is allowed and no user was found return ( -
+

+
- +
+
); diff --git a/apps/login/src/app/(login)/idp/page.tsx b/apps/login/src/app/(login)/idp/page.tsx index 839efada0ba..99b1ca81c07 100644 --- a/apps/login/src/app/(login)/idp/page.tsx +++ b/apps/login/src/app/(login)/idp/page.tsx @@ -9,12 +9,10 @@ import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("idp"); - return { title: t('title')}; + return { title: t("title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const requestId = searchParams?.requestId; @@ -37,19 +35,22 @@ export default async function Page(props: { return ( -
+

+
+
{!!identityProviders?.length && ( )}
diff --git a/apps/login/src/app/(login)/layout.tsx b/apps/login/src/app/(login)/layout.tsx index 9c0aefab61a..21425073bf8 100644 --- a/apps/login/src/app/(login)/layout.tsx +++ b/apps/login/src/app/(login)/layout.tsx @@ -1,13 +1,14 @@ import "@/styles/globals.scss"; +import { BackgroundWrapper } from "@/components/background-wrapper"; import { LanguageProvider } from "@/components/language-provider"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Skeleton } from "@/components/skeleton"; -import { Theme } from "@/components/theme"; import { ThemeProvider } from "@/components/theme-provider"; import * as Tooltip from "@radix-ui/react-tooltip"; import { Lato } from "next/font/google"; import { ReactNode, Suspense } from "react"; +import ThemeSwitch from "@/components/theme-switch"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -30,7 +31,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
@@ -38,24 +39,24 @@ export default async function RootLayout({ children }: { children: ReactNode })
- +
-
+ } > -
-
- {children} -
+
+
{children}
+
- +
-
+ diff --git a/apps/login/src/app/(login)/loginname/page.tsx b/apps/login/src/app/(login)/loginname/page.tsx index 7694e43b198..0264edb2fc3 100644 --- a/apps/login/src/app/(login)/loginname/page.tsx +++ b/apps/login/src/app/(login)/loginname/page.tsx @@ -3,12 +3,7 @@ import { SignInWithIdp } from "@/components/sign-in-with-idp"; import { Translated } from "@/components/translated"; import { UsernameForm } from "@/components/username-form"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { - getActiveIdentityProviders, - getBrandingSettings, - getDefaultOrg, - getLoginSettings, -} from "@/lib/zitadel"; +import { getActiveIdentityProviders, getBrandingSettings, getDefaultOrg, getLoginSettings } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -16,12 +11,10 @@ import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("loginname"); - return { title: t('title')}; + return { title: t("title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const loginName = searchParams?.loginName; @@ -67,14 +60,16 @@ export default async function Page(props: { return ( -
+

+
+
}) { return ( -
+

@@ -27,6 +27,7 @@ export default async function Page(props: { searchParams: Promise }) {

+
); } diff --git a/apps/login/src/app/(login)/logout/page.tsx b/apps/login/src/app/(login)/logout/page.tsx index d20489a3a4b..f35b5155baf 100644 --- a/apps/login/src/app/(login)/logout/page.tsx +++ b/apps/login/src/app/(login)/logout/page.tsx @@ -66,14 +66,16 @@ export default async function Page(props: { searchParams: Promise -
+

-

+

+
+
{ const t = await getTranslations("mfa"); - return { title: t('verify.title')}; + return { title: t("verify.title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const { loginName, requestId, organization, sessionId } = searchParams; @@ -35,11 +29,7 @@ export default async function Page(props: { ? await loadSessionById(serviceUrl, sessionId, organization) : await loadSessionByLoginname(serviceUrl, loginName, organization); - async function loadSessionByLoginname( - serviceUrl: string, - loginName?: string, - organization?: string, - ) { + async function loadSessionByLoginname(serviceUrl: string, loginName?: string, organization?: string) { return loadMostRecentSession({ serviceUrl, sessionParams: { @@ -61,11 +51,7 @@ export default async function Page(props: { }); } - async function loadSessionById( - host: string, - sessionId: string, - organization?: string, - ) { + async function loadSessionById(host: string, sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); return getSession({ serviceUrl, @@ -93,11 +79,10 @@ export default async function Page(props: { return ( -
+

-

@@ -110,7 +95,9 @@ export default async function Page(props: { searchParams={searchParams} > )} +
+
{!(loginName || sessionId) && ( diff --git a/apps/login/src/app/(login)/mfa/set/page.tsx b/apps/login/src/app/(login)/mfa/set/page.tsx index c966a4d881d..27377b8b4f6 100644 --- a/apps/login/src/app/(login)/mfa/set/page.tsx +++ b/apps/login/src/app/(login)/mfa/set/page.tsx @@ -114,7 +114,7 @@ export default async function Page(props: { searchParams: Promise -
+

@@ -131,38 +131,42 @@ export default async function Page(props: { searchParams: Promise )} +
- {!(loginName || sessionId) && ( - - - - )} +
+
+ {!(loginName || sessionId) && ( + + + + )} - {!valid && ( - - - - )} + {!valid && ( + + + + )} - {valid && loginSettings && sessionWithData && sessionWithData.factors?.user?.id && ( - - )} + {valid && loginSettings && sessionWithData && sessionWithData.factors?.user?.id && ( + + )} -
- - +
+ + +
diff --git a/apps/login/src/app/(login)/otp/[method]/page.tsx b/apps/login/src/app/(login)/otp/[method]/page.tsx index 4e8bfe86068..1f06391712b 100644 --- a/apps/login/src/app/(login)/otp/[method]/page.tsx +++ b/apps/login/src/app/(login)/otp/[method]/page.tsx @@ -71,7 +71,7 @@ export default async function Page(props: { return ( -
+

@@ -107,7 +107,9 @@ export default async function Page(props: { searchParams={searchParams} > )} +
+
{method && session && ( -
+

+ + {totpResponse && "uri" in totpResponse && "secret" in totpResponse ? ( +

+ +

+ ) : ( +

+ {method === "email" + ? "Code via email was successfully added." + : method === "sms" + ? "Code via SMS was successfully added." + : ""} +

+ )} + {!session && (
@@ -151,53 +159,33 @@ export default async function Page(props: { searchParams={searchParams} > )} +
+
{totpResponse && "uri" in totpResponse && "secret" in totpResponse ? ( - <> -

- -

-
- -
{" "} - +
+ +
) : ( - <> -

- {method === "email" - ? "Code via email was successfully added." - : method === "sms" - ? "Code via SMS was successfully added." - : ""} -

+
+ + -
- - - - - - -
- + + + +
)}
diff --git a/apps/login/src/app/(login)/passkey/page.tsx b/apps/login/src/app/(login)/passkey/page.tsx index 7a287d5acf4..ab1c9d76fff 100644 --- a/apps/login/src/app/(login)/passkey/page.tsx +++ b/apps/login/src/app/(login)/passkey/page.tsx @@ -13,16 +13,13 @@ import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("passkey"); - return { title: t('verify.title')}; + return { title: t("verify.title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; - const { loginName, altPassword, requestId, organization, sessionId } = - searchParams; + const { loginName, altPassword, requestId, organization, sessionId } = searchParams; const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -34,11 +31,7 @@ export default async function Page(props: { sessionParams: { loginName, organization }, }); - async function loadSessionById( - serviceUrl: string, - sessionId: string, - organization?: string, - ) { + async function loadSessionById(serviceUrl: string, sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); return getSession({ serviceUrl, @@ -58,11 +51,15 @@ export default async function Page(props: { return ( -
+

+

+ +

+ {sessionFactors && ( )} -

- -

+
+
{!(loginName || sessionId) && ( )} +
+
{(loginName || sessionId) && ( +

+ +

+ {session ? ( ) : null} -

- -

+
+
diff --git a/apps/login/src/app/(login)/password/change/page.tsx b/apps/login/src/app/(login)/password/change/page.tsx index 690c1dcb3f8..9a3b73c9235 100644 --- a/apps/login/src/app/(login)/password/change/page.tsx +++ b/apps/login/src/app/(login)/password/change/page.tsx @@ -5,23 +5,17 @@ import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; -import { - getBrandingSettings, - getLoginSettings, - getPasswordComplexitySettings, -} from "@/lib/zitadel"; +import { getBrandingSettings, getLoginSettings, getPasswordComplexitySettings } from "@/lib/zitadel"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("password"); - return { title: t('change.title')}; + return { title: t("change.title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -55,25 +49,20 @@ export default async function Page(props: { return ( -
-

- {sessionFactors?.factors?.user?.displayName ?? ( - - )} -

+
+

{sessionFactors?.factors?.user?.displayName ?? }

{/* show error only if usernames should be shown to be unknown */} - {(!sessionFactors || !loginName) && - !loginSettings?.ignoreUnknownUsernames && ( -
- - - -
- )} + {(!sessionFactors || !loginName) && !loginSettings?.ignoreUnknownUsernames && ( +
+ + + +
+ )} {sessionFactors && ( )} +
- {passwordComplexity && - loginName && - sessionFactors?.factors?.user?.id ? ( +
+ {passwordComplexity && loginName && sessionFactors?.factors?.user?.id ? ( { const t = await getTranslations("password"); - return { title: t('verify.title')}; + return { title: t("verify.title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; let { loginName, organization, requestId } = searchParams; @@ -66,26 +60,12 @@ export default async function Page(props: { return ( -
-

- {sessionFactors?.factors?.user?.displayName ?? ( - - )} -

-

+

+

{sessionFactors?.factors?.user?.displayName ?? }

+

- {/* show error only if usernames should be shown to be unknown */} - {(!sessionFactors || !loginName) && - !loginSettings?.ignoreUnknownUsernames && ( -
- - - -
- )} - {sessionFactors && ( )} +
+ +
+ {/* show error only if usernames should be shown to be unknown */} + {(!sessionFactors || !loginName) && !loginSettings?.ignoreUnknownUsernames && ( +
+ + + +
+ )} {loginName && ( { const t = await getTranslations("password"); - return { title: t('set.title')}; + return { title: t("set.title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; - const { userId, loginName, organization, requestId, code, initial } = - searchParams; + const { userId, loginName, organization, requestId, code, initial } = searchParams; const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -76,12 +68,8 @@ export default async function Page(props: { return ( -
-

- {session?.factors?.user?.displayName ?? ( - - )} -

+
+

{session?.factors?.user?.displayName ?? }

@@ -110,16 +98,16 @@ export default async function Page(props: { searchParams={searchParams} > ) : null} +
+
{!initial && ( )} - {passwordComplexity && - (loginName ?? user?.preferredLoginName) && - (userId ?? session?.factors?.user?.id) ? ( + {passwordComplexity && (loginName ?? user?.preferredLoginName) && (userId ?? session?.factors?.user?.id) ? ( -
+

@@ -79,20 +79,23 @@ export default async function Page(props: { searchParams: Promise

+
); } return ( -
+

+
+
{!organization && ( diff --git a/apps/login/src/app/(login)/register/password/page.tsx b/apps/login/src/app/(login)/register/password/page.tsx index e9689f0f5e3..28f85c7865d 100644 --- a/apps/login/src/app/(login)/register/password/page.tsx +++ b/apps/login/src/app/(login)/register/password/page.tsx @@ -12,9 +12,7 @@ import { import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { headers } from "next/headers"; -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; let { firstname, lastname, email, organization, requestId } = searchParams; @@ -62,17 +60,20 @@ export default async function Page(props: {

+
) : loginSettings?.allowRegister && loginSettings.allowUsernamePassword ? ( -
+

+
+
{legal && passwordComplexitySettings && ( ) : ( -
+

@@ -95,6 +96,7 @@ export default async function Page(props: {

+
); } diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index a9b0660e24f..fb97a48b646 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -3,18 +3,11 @@ import { Button, ButtonVariants } from "@/components/button"; import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; -import { - getMostRecentCookieWithLoginname, - getSessionCookieById, -} from "@/lib/cookies"; +import { getMostRecentCookieWithLoginname, getSessionCookieById } from "@/lib/cookies"; import { completeDeviceAuthorization } from "@/lib/server/device"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; -import { - getBrandingSettings, - getLoginSettings, - getSession, -} from "@/lib/zitadel"; +import { getBrandingSettings, getLoginSettings, getSession } from "@/lib/zitadel"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; @@ -22,14 +15,10 @@ import Link from "next/link"; export async function generateMetadata(): Promise { const t = await getTranslations("signedin"); - return { title: t('title', { user: '' })}; + return { title: t("title", { user: "" }) }; } -async function loadSessionById( - serviceUrl: string, - sessionId: string, - organization?: string, -) { +async function loadSessionById(serviceUrl: string, sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); return getSession({ serviceUrl, @@ -70,7 +59,7 @@ export default async function Page(props: { searchParams: Promise }) { }).catch((err) => { return ( -
+

@@ -79,6 +68,7 @@ export default async function Page(props: { searchParams: Promise }) {

{err.message}
+
); }); @@ -101,13 +91,9 @@ export default async function Page(props: { searchParams: Promise }) { return ( -
+

- +

@@ -119,11 +105,12 @@ export default async function Page(props: { searchParams: Promise }) { showDropdown={!(requestId && requestId.startsWith("device_"))} searchParams={searchParams} /> +

+
{requestId && requestId.startsWith("device_") && ( - You can now close this window and return to the device where you - started the authorization process to continue. + You can now close this window and return to the device where you started the authorization process to continue. )} @@ -132,11 +119,7 @@ export default async function Page(props: { searchParams: Promise }) { - diff --git a/apps/login/src/app/(login)/u2f/page.tsx b/apps/login/src/app/(login)/u2f/page.tsx index 558314e5bee..5ca5542b0f0 100644 --- a/apps/login/src/app/(login)/u2f/page.tsx +++ b/apps/login/src/app/(login)/u2f/page.tsx @@ -51,11 +51,15 @@ export default async function Page(props: { searchParams: Promise -
+

+

+ +

+ {sessionFactors && ( )} -

- -

{!(loginName || sessionId) && ( )} +
+
{(loginName || sessionId) && ( { const t = await getTranslations("u2f"); - return { title: t('set.title')}; + return { title: t("set.title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const { loginName, organization, requestId, checkAfter } = searchParams; @@ -45,6 +43,10 @@ export default async function Page(props: { +

+ +

+ {sessionFactors && ( )} -

- {" "} - -

+
+
{!sessionFactors && (
diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index e1aa916983a..5a24b966fc8 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -120,14 +120,29 @@ export default async function Page(props: { searchParams: Promise }) { return ( -
+

-

+

+ {sessionFactors ? ( + + ) : ( + user && ( + + ) + )} +
+ +
{!id && (
@@ -144,19 +159,6 @@ export default async function Page(props: { searchParams: Promise }) {
)} - {sessionFactors ? ( - - ) : ( - user && ( - - ) - )} - }) { return ( -
+

@@ -67,14 +67,11 @@ export default async function Page(props: { searchParams: Promise }) { > ) : ( user && ( - + ) )}
+
); } diff --git a/apps/login/src/components/LoginLayout.tsx b/apps/login/src/components/LoginLayout.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/login/src/components/avatar.test.tsx b/apps/login/src/components/avatar.test.tsx new file mode 100644 index 00000000000..db577a9b854 --- /dev/null +++ b/apps/login/src/components/avatar.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { Avatar, getInitials } from "./avatar"; + +// Mock next-themes +vi.mock("next-themes", () => ({ + useTheme: () => ({ + resolvedTheme: "light", + }), +})); + +// Mock next/image +vi.mock("next/image", () => ({ + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})); + +describe("Avatar Component", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("getInitials", () => { + it("should get initials from full name", () => { + const initials = getInitials("John Doe", "john.doe@example.com"); + expect(initials).toBe("JD"); + }); + + it("should get single initial from single name", () => { + const initials = getInitials("John", "john@example.com"); + expect(initials).toBe("J"); + }); + + it("should get initials from loginName when name is empty", () => { + const initials = getInitials("", "john.doe@example.com"); + expect(initials.length).toBeGreaterThan(0); + }); + + it("should handle loginName with underscore separator", () => { + const initials = getInitials("", "john_doe@example.com"); + expect(initials).toBe("jd"); + }); + + it("should handle loginName with dash separator", () => { + const initials = getInitials("", "john-doe@example.com"); + expect(initials).toBe("jd"); + }); + + it("should handle loginName with dot separator", () => { + const initials = getInitials("", "john.doe@example.com"); + expect(initials).toBe("jd"); + }); + + it("should get initials from username part of email", () => { + const initials = getInitials("", "testuser@example.com"); + expect(initials.length).toBeGreaterThan(0); + }); + }); + + describe("Component Rendering", () => { + it("should render avatar with initials", () => { + const { container } = render(); + expect(container.querySelector(".avatar, [class*='avatar'], div")).toBeTruthy(); + }); + + it("should render with different sizes", () => { + const sizes: Array<"small" | "base" | "large"> = ["small", "base", "large"]; + + sizes.forEach((size) => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + }); + + it("should render with shadow prop", () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + + it("should render without shadow prop", () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + + it("should render with image URL", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeTruthy(); + }); + }); + + describe("Theme Integration", () => { + it("should apply theme-based roundness", () => { + const { container } = render(); + const avatar = container.firstChild as HTMLElement; + // Should render with some styling + expect(avatar).toBeTruthy(); + }); + + it("should respect theme roundness changes", () => { + // Default theme + const { container: container1 } = render(); + const avatar1 = container1.firstChild as HTMLElement; + + // Change theme + process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy"; + const { container: container2 } = render(); + const avatar2 = container2.firstChild as HTMLElement; + + // Both should render + expect(avatar1).toBeTruthy(); + expect(avatar2).toBeTruthy(); + }); + + it("should render with full roundness", () => { + process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full"; + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + + it("should render with mid roundness", () => { + process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "mid"; + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + }); + + describe("Color Generation", () => { + it("should generate consistent colors for same loginName", () => { + const { container: container1 } = render(); + const { container: container2 } = render(); + + expect(container1.firstChild).toBeTruthy(); + expect(container2.firstChild).toBeTruthy(); + }); + + it("should render for different loginNames", () => { + const { container: container1 } = render(); + const { container: container2 } = render(); + + expect(container1.firstChild).toBeTruthy(); + expect(container2.firstChild).toBeTruthy(); + }); + }); + + describe("Props Validation", () => { + it("should handle null name", () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + + it("should handle undefined name", () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + + it("should require loginName", () => { + const { container } = render(); + expect(container.firstChild).toBeTruthy(); + }); + }); +}); diff --git a/apps/login/src/components/avatar.tsx b/apps/login/src/components/avatar.tsx index e17905e633c..b9592168df5 100644 --- a/apps/login/src/components/avatar.tsx +++ b/apps/login/src/components/avatar.tsx @@ -2,6 +2,7 @@ import { ColorShade, getColorHash } from "@/helpers/colors"; import { useTheme } from "next-themes"; +import { getComponentRoundness } from "@/lib/theme"; interface AvatarProps { name: string | null | undefined; @@ -38,9 +39,15 @@ export function getInitials(name: string, loginName: string) { return credentials; } +// Helper function to get avatar roundness from theme +function getAvatarRoundness(): string { + return getComponentRoundness("avatar"); +} + export function Avatar({ size = "base", name, loginName, imageUrl, shadow }: AvatarProps) { const { resolvedTheme } = useTheme(); const credentials = getInitials(name ?? loginName, loginName); + const avatarRoundness = getAvatarRoundness(); const color: ColorShade = getColorHash(loginName); @@ -56,7 +63,7 @@ export function Avatar({ size = "base", name, loginName, imageUrl, shadow }: Ava return (
) : ( diff --git a/apps/login/src/components/back-button.tsx b/apps/login/src/components/back-button.tsx index 31d4a880ad2..434d723ff9d 100644 --- a/apps/login/src/components/back-button.tsx +++ b/apps/login/src/components/back-button.tsx @@ -7,11 +7,7 @@ import { Translated } from "./translated"; export function BackButton() { const router = useRouter(); return ( - ); diff --git a/apps/login/src/components/background-wrapper.tsx b/apps/login/src/components/background-wrapper.tsx new file mode 100644 index 00000000000..e5c879b6b75 --- /dev/null +++ b/apps/login/src/components/background-wrapper.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useThemeConfig } from "@/lib/theme-hooks"; +import { ReactNode } from "react"; + +/** + * BackgroundWrapper component handles applying background images from theme configuration. + * This needs to be a client component to access environment variables via the theme hook. + */ +export function BackgroundWrapper({ children, className = "" }: { children: ReactNode; className?: string }) { + const themeConfig = useThemeConfig(); + + const backgroundStyle = themeConfig.backgroundImage + ? { + backgroundImage: `url(${themeConfig.backgroundImage})`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + : {}; + + return ( +
+ {children} +
+ ); +} diff --git a/apps/login/src/components/button.test.tsx b/apps/login/src/components/button.test.tsx new file mode 100644 index 00000000000..6dd9cb5bf7a --- /dev/null +++ b/apps/login/src/components/button.test.tsx @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render } from "@testing-library/react"; +import { Button, ButtonSizes, ButtonVariants, ButtonColors, getButtonClasses } from "./button"; + +describe("Button Component", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("Component Rendering", () => { + it("should render button with children", () => { + const { getByText } = render(); + expect(getByText("Click me")).toBeTruthy(); + }); + + it("should apply custom className", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button?.className).toContain("custom-class"); + }); + + it("should pass through native button props", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button?.disabled).toBe(true); + }); + }); + + describe("Button Variants", () => { + it("should render primary variant by default", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + // Primary should have background color + expect(button?.className).toMatch(/bg-/); + }); + + it("should render secondary variant", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + // Secondary should have border + expect(button?.className).toMatch(/border/); + }); + + it("should render all variant types", () => { + const variants = [ButtonVariants.Primary, ButtonVariants.Secondary, ButtonVariants.Destructive]; + + variants.forEach((variant) => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + }); + }); + }); + + describe("Button Sizes", () => { + it("should render small size by default", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + // Should have padding styles + expect(button?.className).toMatch(/p[xy]?-/); + }); + + it("should render large size", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + // Should have padding styles + expect(button?.className).toMatch(/p[xy]?-/); + }); + + it("should render all size types", () => { + const sizes = [ButtonSizes.Small, ButtonSizes.Large]; + + sizes.forEach((size) => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + }); + }); + }); + + describe("Button Colors", () => { + it("should render primary color by default", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + // Should have background color + expect(button?.className).toMatch(/bg-/); + }); + + it("should render warn color", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + // Should have background color + expect(button?.className).toMatch(/bg-/); + }); + + it("should render all color types", () => { + const colors = [ButtonColors.Neutral, ButtonColors.Primary, ButtonColors.Warn]; + + colors.forEach((color) => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + }); + }); + }); + + describe("Theme Integration", () => { + it("should apply theme-based roundness", () => { + const { container } = render(); + const button = container.querySelector("button"); + // Should have some roundness class applied + expect(button?.className).toBeTruthy(); + expect(button?.className).toMatch(/rounded/); + }); + + it("should completely override theme roundness with custom roundness prop", () => { + // Set theme to have full roundness + process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full"; + const customRoundness = "custom-round-test"; + + const { container } = render(); + const button = container.querySelector("button"); + + // Should contain the custom roundness + expect(button?.className).toContain(customRoundness); + + // The custom prop completely replaces theme roundness + expect(button).toBeTruthy(); + }); + + it("should change appearance when theme changes", () => { + // Default theme + const { container: container1 } = render(); + const button1 = container1.querySelector("button"); + const defaultClasses = button1?.className; + + // Change theme + process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full"; + const { container: container2 } = render(); + const button2 = container2.querySelector("button"); + + // Classes should be present (we don't check exact values) + expect(defaultClasses).toBeTruthy(); + expect(button2?.className).toBeTruthy(); + }); + }); + + describe("getButtonClasses", () => { + it("should return valid class string", () => { + const classes = getButtonClasses(ButtonSizes.Small, ButtonVariants.Primary, ButtonColors.Primary); + expect(typeof classes).toBe("string"); + expect(classes.length).toBeGreaterThan(0); + }); + + it("should include custom roundness classes", () => { + const customRoundness = "rounded-2xl"; + const classes = getButtonClasses(ButtonSizes.Small, ButtonVariants.Primary, ButtonColors.Primary, customRoundness); + expect(classes).toContain(customRoundness); + }); + + it("should include appearance classes", () => { + const appearance = "shadow-lg"; + const classes = getButtonClasses( + ButtonSizes.Small, + ButtonVariants.Primary, + ButtonColors.Primary, + "rounded-md", + appearance, + ); + expect(classes).toContain(appearance); + }); + + it("should generate different classes for different variants", () => { + const primaryClasses = getButtonClasses(ButtonSizes.Small, ButtonVariants.Primary, ButtonColors.Primary); + const secondaryClasses = getButtonClasses(ButtonSizes.Small, ButtonVariants.Secondary, ButtonColors.Primary); + + expect(primaryClasses).not.toBe(secondaryClasses); + }); + + it("should generate different classes for different sizes", () => { + const smallClasses = getButtonClasses(ButtonSizes.Small, ButtonVariants.Primary, ButtonColors.Primary); + const largeClasses = getButtonClasses(ButtonSizes.Large, ButtonVariants.Primary, ButtonColors.Primary); + + expect(smallClasses).not.toBe(largeClasses); + }); + }); + + describe("Accessibility", () => { + it("should have type='button' by default", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button?.type).toBe("button"); + }); + + it("should support disabled state", () => { + const { container } = render(); + const button = container.querySelector("button"); + expect(button?.disabled).toBe(true); + // Should have disabled styles + expect(button?.className).toMatch(/disabled:/); + }); + }); +}); diff --git a/apps/login/src/components/button.tsx b/apps/login/src/components/button.tsx index 59f7af39d15..20cfacb142e 100644 --- a/apps/login/src/components/button.tsx +++ b/apps/login/src/components/button.tsx @@ -1,5 +1,7 @@ import { clsx } from "clsx"; import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react"; +import { ThemeableProps } from "@/lib/themeUtils"; +import { getThemeConfig, getComponentRoundness, APPEARANCE_STYLES } from "@/lib/theme"; export enum ButtonSizes { Small = "Small", @@ -18,35 +20,50 @@ export enum ButtonColors { Warn = "Warn", } -export type ButtonProps = DetailedHTMLProps< - ButtonHTMLAttributes, - HTMLButtonElement -> & { +export type ButtonProps = DetailedHTMLProps, HTMLButtonElement> & { size?: ButtonSizes; variant?: ButtonVariants; color?: ButtonColors; -}; +} & ThemeableProps; export const getButtonClasses = ( size: ButtonSizes, variant: ButtonVariants, color: ButtonColors, + roundnessClasses: string = "rounded-md", // Default fallback + appearance: string = "", // Theme appearance (shadows, borders, etc.) ) => - clsx({ - "box-border font-normal leading-36px text-14px inline-flex items-center rounded-md focus:outline-none transition-colors transition-shadow duration-300": true, - "shadow hover:shadow-xl active:shadow-xl disabled:border-none disabled:bg-gray-300 disabled:text-gray-600 disabled:shadow-none disabled:cursor-not-allowed disabled:dark:bg-gray-800 disabled:dark:text-gray-900": - variant === ButtonVariants.Primary, - "bg-primary-light-500 dark:bg-primary-dark-500 hover:bg-primary-light-400 hover:dark:bg-primary-dark-400 text-primary-light-contrast-500 dark:text-primary-dark-contrast-500": - variant === ButtonVariants.Primary && color !== ButtonColors.Warn, - "bg-warn-light-500 dark:bg-warn-dark-500 hover:bg-warn-light-400 hover:dark:bg-warn-dark-400 text-white dark:text-white": - variant === ButtonVariants.Primary && color === ButtonColors.Warn, - "border border-button-light-border dark:border-button-dark-border text-gray-950 hover:bg-gray-500 hover:bg-opacity-20 hover:dark:bg-white hover:dark:bg-opacity-10 focus:bg-gray-500 focus:bg-opacity-20 focus:dark:bg-white focus:dark:bg-opacity-10 dark:text-white disabled:text-gray-600 disabled:hover:bg-transparent disabled:dark:hover:bg-transparent disabled:cursor-not-allowed disabled:dark:text-gray-900": - variant === ButtonVariants.Secondary, - "border border-button-light-border dark:border-button-dark-border text-warn-light-500 dark:text-warn-dark-500 hover:bg-warn-light-500 hover:bg-opacity-10 dark:hover:bg-warn-light-500 dark:hover:bg-opacity-10 focus:bg-warn-light-500 focus:bg-opacity-20 dark:focus:bg-warn-light-500 dark:focus:bg-opacity-20": - color === ButtonColors.Warn && variant !== ButtonVariants.Primary, - "px-16 py-2": size === ButtonSizes.Large, - "px-4 h-[36px]": size === ButtonSizes.Small, - }); + clsx( + { + "box-border leading-36px text-14px inline-flex items-center focus:outline-none transition-colors transition-shadow duration-300": true, + "disabled:border-none disabled:bg-gray-300 disabled:text-gray-600 disabled:shadow-none disabled:cursor-not-allowed disabled:dark:bg-gray-800 disabled:dark:text-gray-900": + variant === ButtonVariants.Primary, + "bg-primary-light-500 dark:bg-primary-dark-500 hover:bg-primary-light-400 hover:dark:bg-primary-dark-400 text-primary-light-contrast-500 dark:text-primary-dark-contrast-500": + variant === ButtonVariants.Primary && color !== ButtonColors.Warn, + "bg-warn-light-500 dark:bg-warn-dark-500 hover:bg-warn-light-400 hover:dark:bg-warn-dark-400 text-white dark:text-white": + variant === ButtonVariants.Primary && color === ButtonColors.Warn, + "border border-button-light-border dark:border-button-dark-border text-gray-950 hover:bg-gray-500 hover:bg-opacity-20 hover:dark:bg-white hover:dark:bg-opacity-10 focus:bg-gray-500 focus:bg-opacity-20 focus:dark:bg-white focus:dark:bg-opacity-10 dark:text-white disabled:text-gray-600 disabled:hover:bg-transparent disabled:dark:hover:bg-transparent disabled:cursor-not-allowed disabled:dark:text-gray-900": + variant === ButtonVariants.Secondary, + "border border-button-light-border dark:border-button-dark-border text-warn-light-500 dark:text-warn-dark-500 hover:bg-warn-light-500 hover:bg-opacity-10 dark:hover:bg-warn-light-500 dark:hover:bg-opacity-10 focus:bg-warn-light-500 focus:bg-opacity-20 dark:focus:bg-warn-light-500 dark:focus:bg-opacity-20": + color === ButtonColors.Warn && variant !== ButtonVariants.Primary, + "px-16 py-2": size === ButtonSizes.Large, + "px-4 h-[36px]": size === ButtonSizes.Small, + }, + roundnessClasses, // Apply the full roundness classes directly + appearance, // Apply appearance-specific styling (shadows, borders, etc.) + ); + +// Helper function to get default button roundness from theme +function getDefaultButtonRoundness(): string { + return getComponentRoundness("button"); +} + +// Helper function to get default button appearance from centralized theme system +function getDefaultButtonAppearance(): string { + const themeConfig = getThemeConfig(); + const appearance = APPEARANCE_STYLES[themeConfig.appearance]; + return appearance?.button || "border border-button-light-border dark:border-button-dark-border"; // Fallback to flat design +} // eslint-disable-next-line react/display-name export const Button = forwardRef( @@ -57,17 +74,24 @@ export const Button = forwardRef( variant = ButtonVariants.Primary, size = ButtonSizes.Small, color = ButtonColors.Primary, + roundness, // Will use theme default if not provided ...props }, ref, - ) => ( - - ), + ) => { + // Use theme-based values if not explicitly provided + const actualRoundness = roundness || getDefaultButtonRoundness(); + const actualAppearance = getDefaultButtonAppearance(); + + return ( + + ); + }, ); diff --git a/apps/login/src/components/card.test.tsx b/apps/login/src/components/card.test.tsx new file mode 100644 index 00000000000..7de8d14e347 --- /dev/null +++ b/apps/login/src/components/card.test.tsx @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render } from "@testing-library/react"; +import { Card } from "./card"; + +describe("Card Component", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("Component Rendering", () => { + it("should render card with children", () => { + const { getByText } = render( + +
Card Content
+
, + ); + expect(getByText("Card Content")).toBeTruthy(); + }); + + it("should render multiple children", () => { + const { getByText } = render( + +

Title

+

Description

+
, + ); + expect(getByText("Title")).toBeTruthy(); + expect(getByText("Description")).toBeTruthy(); + }); + + it("should apply custom className", () => { + const { container } = render(Content); + const card = container.firstChild as HTMLElement; + expect(card?.className).toContain("custom-class"); + }); + + it("should pass through native div props", () => { + const { container } = render( + + Content + , + ); + const card = container.firstChild as HTMLElement; + expect(card?.id).toBe("test-card"); + expect(card?.getAttribute("data-testid")).toBe("card"); + }); + }); + + describe("Theme Integration", () => { + it("should apply theme-based roundness", () => { + const { container } = render(Themed Card); + const card = container.firstChild as HTMLElement; + // Should have some roundness class applied + expect(card?.className).toBeTruthy(); + expect(card?.className).toMatch(/rounded/); + }); + + it("should completely override theme roundness with custom roundness prop", () => { + // Set theme to have mid roundness + process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "mid"; + const customRoundness = "custom-card-round"; + + const { container } = render(Custom); + const card = container.firstChild as HTMLElement; + + // Should contain the custom roundness + expect(card?.className).toContain(customRoundness); + + // The custom prop completely replaces theme roundness, not merged + expect(card).toBeTruthy(); + }); + + it("should completely override theme padding with custom padding prop", () => { + // Set theme to have regular spacing + process.env.NEXT_PUBLIC_THEME_SPACING = "regular"; + const customPadding = "custom-card-padding"; + + const { container } = render(Custom); + const card = container.firstChild as HTMLElement; + + // Should contain the custom padding + expect(card?.className).toContain(customPadding); + + // The custom prop completely replaces theme padding, not merged + expect(card).toBeTruthy(); + }); + + it("should respect theme appearance changes", () => { + // Default theme + const { container: container1 } = render(Default); + const card1 = container1.firstChild as HTMLElement; + const defaultClasses = card1?.className; + + // Change theme appearance + process.env.NEXT_PUBLIC_THEME_APPEARANCE = "glass"; + const { container: container2 } = render(Glass); + const card2 = container2.firstChild as HTMLElement; + + // Classes should be present + expect(defaultClasses).toBeTruthy(); + expect(card2?.className).toBeTruthy(); + }); + + it("should respect theme spacing changes when no override provided", () => { + // Default spacing + const { container: container1 } = render(Default); + const card1 = container1.firstChild as HTMLElement; + const defaultClasses = card1?.className; + + // Compact spacing + process.env.NEXT_PUBLIC_THEME_SPACING = "compact"; + const { container: container2 } = render(Compact); + const card2 = container2.firstChild as HTMLElement; + + // Classes should be present + expect(defaultClasses).toBeTruthy(); + expect(card2?.className).toBeTruthy(); + }); + }); + + describe("Styling", () => { + it("should have base card styling", () => { + const { container } = render(Test); + const card = container.firstChild as HTMLElement; + expect(card?.className).toBeTruthy(); + expect(card?.className.length).toBeGreaterThan(0); + // Should have background color + expect(card?.className).toMatch(/bg-/); + }); + + it("should be a div element", () => { + const { container } = render(Test); + const card = container.firstChild as HTMLElement; + expect(card?.tagName).toBe("DIV"); + }); + }); + + describe("Multiple Appearance Themes", () => { + it("should render with flat appearance", () => { + process.env.NEXT_PUBLIC_THEME_APPEARANCE = "flat"; + const { container } = render(Flat Card); + const card = container.firstChild as HTMLElement; + expect(card).toBeTruthy(); + }); + + it("should render with material appearance", () => { + process.env.NEXT_PUBLIC_THEME_APPEARANCE = "material"; + const { container } = render(Material Card); + const card = container.firstChild as HTMLElement; + expect(card).toBeTruthy(); + }); + + it("should render with glass appearance", () => { + process.env.NEXT_PUBLIC_THEME_APPEARANCE = "glass"; + const { container } = render(Glass Card); + const card = container.firstChild as HTMLElement; + expect(card).toBeTruthy(); + }); + }); + + describe("Ref Forwarding", () => { + it("should support ref forwarding", () => { + const { container } = render(Content); + const card = container.firstChild as HTMLElement; + expect(card).toBeTruthy(); + expect(card.nodeType).toBe(1); // Element node + }); + }); +}); diff --git a/apps/login/src/components/card.tsx b/apps/login/src/components/card.tsx new file mode 100644 index 00000000000..f12c7548228 --- /dev/null +++ b/apps/login/src/components/card.tsx @@ -0,0 +1,72 @@ +import { clsx } from "clsx"; +import { HTMLAttributes, forwardRef, ReactNode } from "react"; +import { getThemeConfig, APPEARANCE_STYLES, SPACING_STYLES, getComponentRoundness } from "@/lib/theme"; + +export interface CardProps extends HTMLAttributes { + children: ReactNode; + roundness?: string; // Allow override via props + padding?: string; // Allow override via props +} + +// Helper function to get default card roundness from theme +function getDefaultCardRoundness(): string { + return getComponentRoundness("card"); +} + +// Helper function to get default padding from centralized theme system +function getDefaultCardPadding(): string { + const themeConfig = getThemeConfig(); + return SPACING_STYLES[themeConfig.spacing].padding; +} + +// Helper function to get default background from centralized theme system +function getDefaultCardBackground(): string { + const themeConfig = getThemeConfig(); + const appearance = APPEARANCE_STYLES[themeConfig.appearance]; + + // Use appearance-specific background if defined, otherwise fallback to material design (current system) + return appearance?.background || "bg-background-light-400 dark:bg-background-dark-500"; +} + +// Helper function to get default card styling from centralized theme system +function getDefaultCardStyling(): string { + const themeConfig = getThemeConfig(); + const appearance = APPEARANCE_STYLES[themeConfig.appearance]; + return appearance?.card || "shadow-sm border-0"; // Fallback to material design +} + +// eslint-disable-next-line react/display-name +export const Card = forwardRef( + ( + { + children, + className = "", + roundness, // Will use theme default if not provided + padding, // Will use theme default if not provided + ...props + }, + ref, + ) => { + // Use theme-based values if not explicitly provided + const actualRoundness = roundness || getDefaultCardRoundness(); + const actualPadding = padding || getDefaultCardPadding(); + const actualBackground = getDefaultCardBackground(); + const actualCardStyling = getDefaultCardStyling(); + + return ( +
+ {children} +
+ ); + }, +); diff --git a/apps/login/src/components/dynamic-theme.tsx b/apps/login/src/components/dynamic-theme.tsx index ec92b1c627b..42a82de7435 100644 --- a/apps/login/src/components/dynamic-theme.tsx +++ b/apps/login/src/components/dynamic-theme.tsx @@ -2,42 +2,133 @@ import { Logo } from "@/components/logo"; import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; -import { ReactNode } from "react"; -import { AppAvatar } from "./app-avatar"; +import React, { ReactNode, Children } from "react"; import { ThemeWrapper } from "./theme-wrapper"; +import { Card } from "./card"; +import { useResponsiveLayout } from "@/lib/theme-hooks"; +/** + * DynamicTheme component handles layout switching between traditional top-to-bottom + * and modern side-by-side layouts based on NEXT_PUBLIC_THEME_LAYOUT. + * + * For side-by-side layout: + * - First child: Goes to left side (title, description, etc.) + * - Second child: Goes to right side (forms, buttons, etc.) + * - Single child: Falls back to right side for backward compatibility + * + * For top-to-bottom layout: + * - All children rendered in traditional centered layout + */ export function DynamicTheme({ branding, children, - appName, }: { - children: ReactNode; + children: ReactNode | ((isSideBySide: boolean) => ReactNode); branding?: BrandingSettings; - appName?: string; }) { + const { isSideBySide } = useResponsiveLayout(); + + // Resolve children immediately to avoid passing functions through React + const actualChildren: ReactNode = React.useMemo(() => { + if (typeof children === "function") { + return (children as (isSideBySide: boolean) => ReactNode)(isSideBySide); + } + return children; + }, [children, isSideBySide]); + return ( -
-
-
- {branding && ( - <> - + {isSideBySide + ? // Side-by-side layout: first child goes left, second child goes right + (() => { + const childArray = Children.toArray(actualChildren); + const leftContent = childArray[0] || null; + const rightContent = childArray[1] || null; - {appName && } - - )} -
+ // If there's only one child, it's likely the old format - keep it on the right side + const hasLeftRightStructure = childArray.length === 2; -
{children}
-
-
-
+ return ( +
+ +
+ {/* Left side: First child + branding */} +
+
+ {/* Logo and branding */} + {branding && ( + + )} + + {/* First child content (title, description) - only if we have left/right structure */} + {hasLeftRightStructure && ( +
+ {/* Apply larger styling to the content */} +
+ {leftContent} +
+
+ )} +
+
+ + {/* Right side: Second child (form) or single child if old format */} +
+
+
{hasLeftRightStructure ? rightContent : leftContent}
+
+
+
+
+
+ ); + })() + : // Traditional top-to-bottom layout - center title/description, left-align forms + (() => { + const childArray = Children.toArray(actualChildren); + const titleContent = childArray[0] || null; + const formContent = childArray[1] || null; + const hasMultipleChildren = childArray.length > 1; + + return ( +
+ +
+
+ {branding && ( + + )} +
+ + {hasMultipleChildren ? ( + <> + {/* Title and description - center aligned */} +
{titleContent}
+ + {/* Form content - left aligned */} +
{formContent}
+ + ) : ( + // Single child - use original behavior +
{actualChildren}
+ )} + +
+
+
+
+ ); + })()}
); } diff --git a/apps/login/src/components/idps/base-button.tsx b/apps/login/src/components/idps/base-button.tsx index a38278e7cba..1e00302a5a5 100644 --- a/apps/login/src/components/idps/base-button.tsx +++ b/apps/login/src/components/idps/base-button.tsx @@ -4,6 +4,7 @@ import { clsx } from "clsx"; import { Loader2Icon } from "lucide-react"; import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react"; import { useFormStatus } from "react-dom"; +import { getComponentRoundness, getThemeConfig, APPEARANCE_STYLES } from "@/lib/theme"; export type SignInWithIdentityProviderProps = DetailedHTMLProps< ButtonHTMLAttributes, @@ -13,11 +14,17 @@ export type SignInWithIdentityProviderProps = DetailedHTMLProps< e2e?: string; }; -export const BaseButton = forwardRef< - HTMLButtonElement, - SignInWithIdentityProviderProps ->(function BaseButton(props, ref) { +// Helper function to get default IDP button appearance from centralized theme system +function getDefaultIdpButtonAppearance(): string { + const themeConfig = getThemeConfig(); + const appearance = APPEARANCE_STYLES[themeConfig.appearance]; + return appearance?.["idp-button"] || "border border-divider-light dark:border-divider-dark"; // Fallback to basic border +} + +export const BaseButton = forwardRef(function BaseButton(props, ref) { const formStatus = useFormStatus(); + const buttonRoundness = getComponentRoundness("button"); + const idpButtonAppearance = getDefaultIdpButtonAppearance(); return ( diff --git a/apps/login/src/components/idps/pages/complete-idp.tsx b/apps/login/src/components/idps/pages/complete-idp.tsx index 2061a28e3ee..d6781b96576 100644 --- a/apps/login/src/components/idps/pages/complete-idp.tsx +++ b/apps/login/src/components/idps/pages/complete-idp.tsx @@ -28,14 +28,16 @@ export async function completeIDP({ }) { return ( -
+

+
+
-
+

- {error && ( -
- {{error}} -
- )} + {error &&
{{error}}
}
+
); } diff --git a/apps/login/src/components/idps/pages/linking-success.tsx b/apps/login/src/components/idps/pages/linking-success.tsx index 8d41cd8c329..4b7be47b13f 100644 --- a/apps/login/src/components/idps/pages/linking-success.tsx +++ b/apps/login/src/components/idps/pages/linking-success.tsx @@ -11,19 +11,17 @@ export async function linkingSuccess( ) { return ( -
+

+
- +
+
); diff --git a/apps/login/src/components/idps/pages/login-failed.tsx b/apps/login/src/components/idps/pages/login-failed.tsx index 70c46919bfa..9788ca46117 100644 --- a/apps/login/src/components/idps/pages/login-failed.tsx +++ b/apps/login/src/components/idps/pages/login-failed.tsx @@ -13,12 +13,9 @@ export async function loginFailed(branding?: BrandingSettings, error?: string) {

- {error && ( -
- {{error}} -
- )} + {error &&
{{error}}
}
+
); } diff --git a/apps/login/src/components/idps/pages/login-success.tsx b/apps/login/src/components/idps/pages/login-success.tsx index 6beec160a9d..ba85547ec74 100644 --- a/apps/login/src/components/idps/pages/login-success.tsx +++ b/apps/login/src/components/idps/pages/login-success.tsx @@ -18,12 +18,10 @@ export async function loginSuccess(

+
- +
+
); diff --git a/apps/login/src/components/input.test.tsx b/apps/login/src/components/input.test.tsx new file mode 100644 index 00000000000..556fcf401df --- /dev/null +++ b/apps/login/src/components/input.test.tsx @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TextInput } from "./input"; + +describe("TextInput Component", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("Component Rendering", () => { + it("should render input with label", () => { + render(); + expect(screen.getByText("Email")).toBeTruthy(); + }); + + it("should render input with placeholder", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.placeholder).toBe("Enter username"); + }); + + it("should render input with default value", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.defaultValue).toBe("John Doe"); + }); + + it("should show required indicator when required", () => { + render(); + expect(screen.getByText(/\*/)).toBeTruthy(); + }); + }); + + describe("Input States", () => { + it("should render disabled state", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.disabled).toBe(true); + // Should have disabled/pointer-events styles + expect(input?.className).toMatch(/pointer-events/); + }); + + it("should render error state with message", () => { + render(); + expect(screen.getByText("Invalid email")).toBeTruthy(); + }); + + it("should apply error styling when error is present", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input).toBeTruthy(); + // Should have border-warn or warn-related styles + expect(input?.className).toMatch(/border-warn/); + }); + + it("should render success state with message", () => { + render(); + expect(screen.getByText("Valid email")).toBeTruthy(); + }); + }); + + describe("Theme Integration", () => { + it("should apply theme-based roundness", () => { + const { container } = render(); + const input = container.querySelector("input"); + // Should have some roundness class applied + expect(input?.className).toBeTruthy(); + expect(input?.className).toMatch(/rounded/); + }); + + it("should completely override theme roundness with custom roundness prop", () => { + // Set theme to have full roundness + process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full"; + const customRoundness = "custom-input-round"; + + const { container } = render(); + const input = container.querySelector("input"); + + // Should contain the custom roundness + expect(input?.className).toContain(customRoundness); + + // The custom prop completely replaces theme roundness + expect(input).toBeTruthy(); + }); + + it("should respect theme roundness changes", () => { + // Default theme + const { container: container1 } = render(); + const input1 = container1.querySelector("input"); + const defaultClasses = input1?.className; + + // Change theme + process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full"; + const { container: container2 } = render(); + const input2 = container2.querySelector("input"); + + // Classes should be present + expect(defaultClasses).toBeTruthy(); + expect(input2?.className).toBeTruthy(); + }); + }); + + describe("Input Behavior", () => { + it("should have autocomplete off by default", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.autocomplete).toBe("off"); + }); + + it("should allow custom autocomplete", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.autocomplete).toBe("email"); + }); + + it("should pass through native input props", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.maxLength).toBe(10); + expect(input?.type).toBe("email"); + }); + }); + + describe("Styling", () => { + it("should have consistent base styling", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input).toBeTruthy(); + expect(input?.className).toBeTruthy(); + expect(input?.className.length).toBeGreaterThan(0); + // Should have transition styles + expect(input?.className).toMatch(/transition/); + }); + + it("should apply border styles", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input).toBeTruthy(); + // Should have border-related classes + expect(input?.className).toMatch(/border/); + }); + + it("should have focus styles", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input).toBeTruthy(); + // Should have focus-related classes + expect(input?.className).toMatch(/focus:/); + }); + }); + + describe("Label Styling", () => { + it("should apply error color to label when error exists", () => { + const { container } = render(); + const label = container.querySelector("label"); + expect(label).toBeTruthy(); + }); + + it("should have default label styling", () => { + const { container } = render(); + const label = container.querySelector("label"); + expect(label).toBeTruthy(); + expect(label?.className).toBeTruthy(); + }); + }); + + describe("Accessibility", () => { + it("should connect label to input", () => { + const { container } = render(); + const label = container.querySelector("label"); + const input = container.querySelector("input"); + expect(label).toBeTruthy(); + expect(input).toBeTruthy(); + }); + + it("should show required indicator", () => { + const { container } = render(); + const label = container.querySelector("label"); + expect(label?.textContent).toContain("*"); + }); + }); +}); diff --git a/apps/login/src/components/input.tsx b/apps/login/src/components/input.tsx index 7d29fce691d..960319e9128 100644 --- a/apps/login/src/components/input.tsx +++ b/apps/login/src/components/input.tsx @@ -2,18 +2,10 @@ import { CheckCircleIcon } from "@heroicons/react/24/solid"; import { clsx } from "clsx"; -import { - ChangeEvent, - DetailedHTMLProps, - forwardRef, - InputHTMLAttributes, - ReactNode, -} from "react"; +import { ChangeEvent, DetailedHTMLProps, forwardRef, InputHTMLAttributes, ReactNode } from "react"; +import { getComponentRoundness } from "@/lib/theme"; -export type TextInputProps = DetailedHTMLProps< - InputHTMLAttributes, - HTMLInputElement -> & { +export type TextInputProps = DetailedHTMLProps, HTMLInputElement> & { label: string; suffix?: string; placeholder?: string; @@ -23,18 +15,27 @@ export type TextInputProps = DetailedHTMLProps< disabled?: boolean; onChange?: (value: ChangeEvent) => void; onBlur?: (value: ChangeEvent) => void; + roundness?: string; // Allow override via props }; -const styles = (error: boolean, disabled: boolean) => - clsx({ - "h-[40px] mb-[2px] rounded p-[7px] bg-input-light-background dark:bg-input-dark-background transition-colors duration-300 grow": true, - "border border-input-light-border dark:border-input-dark-border hover:border-black hover:dark:border-white focus:border-primary-light-500 focus:dark:border-primary-dark-500": true, - "focus:outline-none focus:ring-0 text-base text-black dark:text-white placeholder:italic placeholder-gray-700 dark:placeholder-gray-700": true, - "border border-warn-light-500 dark:border-warn-dark-500 hover:border-warn-light-500 hover:dark:border-warn-dark-500 focus:border-warn-light-500 focus:dark:border-warn-dark-500": - error, - "pointer-events-none text-gray-500 dark:text-gray-800 border border-input-light-border dark:border-input-dark-border hover:border-light-hoverborder hover:dark:border-hoverborder cursor-default": - disabled, - }); +const styles = (error: boolean, disabled: boolean, roundnessClasses: string = "rounded-md") => + clsx( + { + "h-[40px] mb-[2px] p-[7px] bg-input-light-background dark:bg-input-dark-background transition-colors duration-300 grow": true, + "border border-input-light-border dark:border-input-dark-border hover:border-black hover:dark:border-white focus:border-primary-light-500 focus:dark:border-primary-dark-500": true, + "focus:outline-none focus:ring-0 text-base text-black dark:text-white placeholder:italic placeholder-gray-700 dark:placeholder-gray-700": true, + "border border-warn-light-500 dark:border-warn-dark-500 hover:border-warn-light-500 hover:dark:border-warn-dark-500 focus:border-warn-light-500 focus:dark:border-warn-dark-500": + error, + "pointer-events-none text-gray-500 dark:text-gray-800 border border-input-light-border dark:border-input-dark-border hover:border-light-hoverborder hover:dark:border-hoverborder cursor-default": + disabled, + }, + roundnessClasses, // Apply the full roundness classes directly + ); + +// Helper function to get default input roundness from theme +function getDefaultInputRoundness(): string { + return getComponentRoundness("input"); +} // eslint-disable-next-line react/display-name export const TextInput = forwardRef( @@ -50,23 +51,23 @@ export const TextInput = forwardRef( success, onChange, onBlur, + roundness, ...props }, ref, ) => { + // Use theme-based roundness if not explicitly provided + const actualRoundness = roundness || getDefaultInputRoundness(); + return (