feat(login): comprehensive theme system (#10848)

# Which Problems Are Solved

This PR introduces a comprehensive theme customization system for the
login application with responsive behavior and enhanced visual options.

<img width="1122" height="578" alt="Screenshot 2025-08-19 at 09 55 24"
src="https://github.com/user-attachments/assets/cdcc8948-533d-4e13-bf45-fdcc24acfb2b"
/>

# 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


<img width="580" height="680" alt="Screenshot 2025-08-19 at 09 22 23"
src="https://github.com/user-attachments/assets/9de8da37-6d56-4fe9-b337-5d8ad2a3ba59"
/>
<img width="599" height="689" alt="Screenshot 2025-08-19 at 09 23 45"
src="https://github.com/user-attachments/assets/26a30cc7-4017-4f4b-8b87-a49466c42b94"
/>
<img width="595" height="681" alt="Screenshot 2025-08-19 at 09 23 17"
src="https://github.com/user-attachments/assets/a3d31088-4545-4f36-aafe-1aae1253d677"
/>
This commit is contained in:
Max Peintner
2025-10-10 10:26:06 +02:00
committed by GitHub
parent 41d8269ead
commit 434aeb275a
65 changed files with 2782 additions and 616 deletions
+168
View File
@@ -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 }) => <img src={src} alt={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", "[email protected]");
expect(initials).toBe("JD");
});
it("should get single initial from single name", () => {
const initials = getInitials("John", "[email protected]");
expect(initials).toBe("J");
});
it("should get initials from loginName when name is empty", () => {
const initials = getInitials("", "[email protected]");
expect(initials.length).toBeGreaterThan(0);
});
it("should handle loginName with underscore separator", () => {
const initials = getInitials("", "[email protected]");
expect(initials).toBe("jd");
});
it("should handle loginName with dash separator", () => {
const initials = getInitials("", "[email protected]");
expect(initials).toBe("jd");
});
it("should handle loginName with dot separator", () => {
const initials = getInitials("", "[email protected]");
expect(initials).toBe("jd");
});
it("should get initials from username part of email", () => {
const initials = getInitials("", "[email protected]");
expect(initials.length).toBeGreaterThan(0);
});
});
describe("Component Rendering", () => {
it("should render avatar with initials", () => {
const { container } = render(<Avatar name="John Doe" loginName="[email protected]" />);
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(<Avatar size={size} name="Test User" loginName="[email protected]" />);
expect(container.firstChild).toBeTruthy();
});
});
it("should render with shadow prop", () => {
const { container } = render(<Avatar name="Test User" loginName="[email protected]" shadow={true} />);
expect(container.firstChild).toBeTruthy();
});
it("should render without shadow prop", () => {
const { container } = render(<Avatar name="Test User" loginName="[email protected]" shadow={false} />);
expect(container.firstChild).toBeTruthy();
});
it("should render with image URL", () => {
const { container } = render(
<Avatar name="Test User" loginName="[email protected]" imageUrl="https://example.com/avatar.jpg" />,
);
expect(container.firstChild).toBeTruthy();
});
});
describe("Theme Integration", () => {
it("should apply theme-based roundness", () => {
const { container } = render(<Avatar name="Test User" loginName="[email protected]" />);
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(<Avatar name="Test User" loginName="[email protected]" />);
const avatar1 = container1.firstChild as HTMLElement;
// Change theme
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy";
const { container: container2 } = render(<Avatar name="Test User" loginName="[email protected]" />);
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(<Avatar name="Test User" loginName="[email protected]" />);
expect(container.firstChild).toBeTruthy();
});
it("should render with mid roundness", () => {
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "mid";
const { container } = render(<Avatar name="Test User" loginName="[email protected]" />);
expect(container.firstChild).toBeTruthy();
});
});
describe("Color Generation", () => {
it("should generate consistent colors for same loginName", () => {
const { container: container1 } = render(<Avatar name="Test" loginName="[email protected]" />);
const { container: container2 } = render(<Avatar name="Test" loginName="[email protected]" />);
expect(container1.firstChild).toBeTruthy();
expect(container2.firstChild).toBeTruthy();
});
it("should render for different loginNames", () => {
const { container: container1 } = render(<Avatar name="User 1" loginName="[email protected]" />);
const { container: container2 } = render(<Avatar name="User 2" loginName="[email protected]" />);
expect(container1.firstChild).toBeTruthy();
expect(container2.firstChild).toBeTruthy();
});
});
describe("Props Validation", () => {
it("should handle null name", () => {
const { container } = render(<Avatar name={null} loginName="[email protected]" />);
expect(container.firstChild).toBeTruthy();
});
it("should handle undefined name", () => {
const { container } = render(<Avatar name={undefined} loginName="[email protected]" />);
expect(container.firstChild).toBeTruthy();
});
it("should require loginName", () => {
const { container } = render(<Avatar name="Test" loginName="[email protected]" />);
expect(container.firstChild).toBeTruthy();
});
});
});
+9 -2
View File
@@ -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 (
<div
className={`dark:group-focus:ring-offset-blue dark:text-blue pointer-events-none flex h-full w-full flex-shrink-0 cursor-default items-center justify-center rounded-full bg-primary-light-500 text-primary-light-contrast-500 transition-colors duration-200 hover:bg-primary-light-400 group-focus:outline-none group-focus:ring-2 group-focus:ring-primary-light-200 dark:bg-primary-dark-300 dark:text-primary-dark-contrast-300 hover:dark:bg-primary-dark-500 dark:group-focus:ring-primary-dark-400 ${
className={`dark:group-focus:ring-offset-blue dark:text-blue pointer-events-none flex h-full w-full flex-shrink-0 cursor-default items-center justify-center bg-primary-light-500 text-primary-light-contrast-500 transition-colors duration-200 hover:bg-primary-light-400 group-focus:outline-none group-focus:ring-2 group-focus:ring-primary-light-200 dark:bg-primary-dark-300 dark:text-primary-dark-contrast-300 hover:dark:bg-primary-dark-500 dark:group-focus:ring-primary-dark-400 ${avatarRoundness} ${
shadow ? "shadow" : ""
} ${
size === "large"
@@ -74,7 +81,7 @@ export function Avatar({ size = "base", name, loginName, imageUrl, shadow }: Ava
height={48}
width={48}
alt="avatar"
className="h-full w-full rounded-full border border-divider-light dark:border-divider-dark"
className={`h-full w-full border border-divider-light dark:border-divider-dark ${avatarRoundness}`}
src={imageUrl}
/>
) : (
+1 -5
View File
@@ -7,11 +7,7 @@ import { Translated } from "./translated";
export function BackButton() {
const router = useRouter();
return (
<Button
onClick={() => router.back()}
type="button"
variant={ButtonVariants.Secondary}
>
<Button onClick={() => router.back()} type="button" variant={ButtonVariants.Secondary}>
<Translated i18nKey="back" namespace="common" />
</Button>
);
@@ -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 (
<div className={className} style={backgroundStyle}>
{children}
</div>
);
}
+215
View File
@@ -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(<Button>Click me</Button>);
expect(getByText("Click me")).toBeTruthy();
});
it("should apply custom className", () => {
const { container } = render(<Button className="custom-class">Test</Button>);
const button = container.querySelector("button");
expect(button?.className).toContain("custom-class");
});
it("should pass through native button props", () => {
const { container } = render(<Button disabled>Test</Button>);
const button = container.querySelector("button");
expect(button?.disabled).toBe(true);
});
});
describe("Button Variants", () => {
it("should render primary variant by default", () => {
const { container } = render(<Button>Primary</Button>);
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(<Button variant={ButtonVariants.Secondary}>Secondary</Button>);
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(<Button variant={variant}>Test</Button>);
const button = container.querySelector("button");
expect(button).toBeTruthy();
});
});
});
describe("Button Sizes", () => {
it("should render small size by default", () => {
const { container } = render(<Button>Small</Button>);
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(<Button size={ButtonSizes.Large}>Large</Button>);
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(<Button size={size}>Test</Button>);
const button = container.querySelector("button");
expect(button).toBeTruthy();
});
});
});
describe("Button Colors", () => {
it("should render primary color by default", () => {
const { container } = render(<Button>Primary Color</Button>);
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(<Button color={ButtonColors.Warn}>Warn</Button>);
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(<Button color={color}>Test</Button>);
const button = container.querySelector("button");
expect(button).toBeTruthy();
});
});
});
describe("Theme Integration", () => {
it("should apply theme-based roundness", () => {
const { container } = render(<Button>Themed</Button>);
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(<Button roundness={customRoundness}>Custom</Button>);
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(<Button>Default</Button>);
const button1 = container1.querySelector("button");
const defaultClasses = button1?.className;
// Change theme
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
const { container: container2 } = render(<Button>Full</Button>);
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(<Button>Test</Button>);
const button = container.querySelector("button");
expect(button?.type).toBe("button");
});
it("should support disabled state", () => {
const { container } = render(<Button disabled>Disabled</Button>);
const button = container.querySelector("button");
expect(button?.disabled).toBe(true);
// Should have disabled styles
expect(button?.className).toMatch(/disabled:/);
});
});
});
+54 -30
View File
@@ -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>,
HTMLButtonElement
> & {
export type ButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, 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<HTMLButtonElement, ButtonProps>(
@@ -57,17 +74,24 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
variant = ButtonVariants.Primary,
size = ButtonSizes.Small,
color = ButtonColors.Primary,
roundness, // Will use theme default if not provided
...props
},
ref,
) => (
<button
type="button"
ref={ref}
className={`${getButtonClasses(size, variant, color)} ${className}`}
{...props}
>
{children}
</button>
),
) => {
// Use theme-based values if not explicitly provided
const actualRoundness = roundness || getDefaultButtonRoundness();
const actualAppearance = getDefaultButtonAppearance();
return (
<button
type="button"
ref={ref}
className={`${getButtonClasses(size, variant, color, actualRoundness, actualAppearance)} ${className}`}
{...props}
>
{children}
</button>
);
},
);
+175
View File
@@ -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>
<div>Card Content</div>
</Card>,
);
expect(getByText("Card Content")).toBeTruthy();
});
it("should render multiple children", () => {
const { getByText } = render(
<Card>
<h1>Title</h1>
<p>Description</p>
</Card>,
);
expect(getByText("Title")).toBeTruthy();
expect(getByText("Description")).toBeTruthy();
});
it("should apply custom className", () => {
const { container } = render(<Card className="custom-class">Content</Card>);
const card = container.firstChild as HTMLElement;
expect(card?.className).toContain("custom-class");
});
it("should pass through native div props", () => {
const { container } = render(
<Card id="test-card" data-testid="card">
Content
</Card>,
);
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(<Card>Themed Card</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(<Card roundness={customRoundness}>Custom</Card>);
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(<Card padding={customPadding}>Custom</Card>);
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(<Card>Default</Card>);
const card1 = container1.firstChild as HTMLElement;
const defaultClasses = card1?.className;
// Change theme appearance
process.env.NEXT_PUBLIC_THEME_APPEARANCE = "glass";
const { container: container2 } = render(<Card>Glass</Card>);
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(<Card>Default</Card>);
const card1 = container1.firstChild as HTMLElement;
const defaultClasses = card1?.className;
// Compact spacing
process.env.NEXT_PUBLIC_THEME_SPACING = "compact";
const { container: container2 } = render(<Card>Compact</Card>);
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(<Card>Test</Card>);
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(<Card>Test</Card>);
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(<Card>Flat Card</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(<Card>Material Card</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(<Card>Glass Card</Card>);
const card = container.firstChild as HTMLElement;
expect(card).toBeTruthy();
});
});
describe("Ref Forwarding", () => {
it("should support ref forwarding", () => {
const { container } = render(<Card>Content</Card>);
const card = container.firstChild as HTMLElement;
expect(card).toBeTruthy();
expect(card.nodeType).toBe(1); // Element node
});
});
});
+72
View File
@@ -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<HTMLDivElement> {
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<HTMLDivElement, CardProps>(
(
{
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 (
<div
ref={ref}
className={clsx(
actualBackground,
actualCardStyling,
actualPadding,
actualRoundness, // Apply the full roundness classes directly
className,
)}
{...props}
>
{children}
</div>
);
},
);
+115 -24
View File
@@ -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 (
<ThemeWrapper branding={branding}>
<div className="rounded-lg bg-background-light-400 px-8 py-12 dark:bg-background-dark-500">
<div className="mx-auto flex flex-col items-center space-y-4">
<div className="relative flex flex-row items-center justify-center gap-8">
{branding && (
<>
<Logo
lightSrc={branding.lightTheme?.logoUrl}
darkSrc={branding.darkTheme?.logoUrl}
height={appName ? 100 : 150}
width={appName ? 100 : 150}
/>
{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 && <AppAvatar appName={appName} />}
</>
)}
</div>
// If there's only one child, it's likely the old format - keep it on the right side
const hasLeftRightStructure = childArray.length === 2;
<div className="w-full">{children}</div>
<div className="flex flex-row justify-between"></div>
</div>
</div>
return (
<div className="relative mx-auto w-full max-w-[1100px] py-4 px-8">
<Card>
<div className="flex min-h-[400px]">
{/* Left side: First child + branding */}
<div className="flex w-1/2 flex-col justify-center p-4 lg:p-8 bg-gradient-to-br from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20">
<div className="max-w-[440px] mx-auto space-y-8">
{/* Logo and branding */}
{branding && (
<Logo
lightSrc={branding.lightTheme?.logoUrl}
darkSrc={branding.darkTheme?.logoUrl}
height={150}
width={150}
/>
)}
{/* First child content (title, description) - only if we have left/right structure */}
{hasLeftRightStructure && (
<div className="space-y-4 text-left flex flex-col items-start">
{/* Apply larger styling to the content */}
<div className="space-y-6 [&_h1]:text-4xl [&_h1]:lg:text-4xl [&_h1]:text-left [&_h1]:text-gray-900 [&_h1]:dark:text-white [&_h1]:leading-tight [&_p]:text-left [&_p]:leading-relaxed [&_p]:text-gray-700 [&_p]:dark:text-gray-300">
{leftContent}
</div>
</div>
)}
</div>
</div>
{/* Right side: Second child (form) or single child if old format */}
<div className="flex w-1/2 items-center justify-center p-4 lg:p-8">
<div className="w-full max-w-[440px]">
<div className="space-y-6">{hasLeftRightStructure ? rightContent : leftContent}</div>
</div>
</div>
</div>
</Card>
</div>
);
})()
: // 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 (
<div className="relative mx-auto w-full max-w-[440px] py-4 px-4">
<Card>
<div className="mx-auto flex flex-col items-center space-y-8">
<div className="relative flex flex-row items-center justify-center -mb-4">
{branding && (
<Logo
lightSrc={branding.lightTheme?.logoUrl}
darkSrc={branding.darkTheme?.logoUrl}
height={150}
width={150}
/>
)}
</div>
{hasMultipleChildren ? (
<>
{/* Title and description - center aligned */}
<div className="w-full text-center flex flex-col items-center mb-4">{titleContent}</div>
{/* Form content - left aligned */}
<div className="w-full">{formContent}</div>
</>
) : (
// Single child - use original behavior
<div className="w-full">{actualChildren}</div>
)}
<div className="flex flex-row justify-between"></div>
</div>
</Card>
</div>
);
})()}
</ThemeWrapper>
);
}
+16 -8
View File
@@ -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<HTMLButtonElement>,
@@ -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<HTMLButtonElement, SignInWithIdentityProviderProps>(function BaseButton(props, ref) {
const formStatus = useFormStatus();
const buttonRoundness = getComponentRoundness("button");
const idpButtonAppearance = getDefaultIdpButtonAppearance();
return (
<button
@@ -26,14 +33,15 @@ export const BaseButton = forwardRef<
ref={ref}
disabled={formStatus.pending}
className={clsx(
"flex flex-1 cursor-pointer flex-row items-center rounded-md border border-divider-light bg-background-light-400 px-4 text-sm text-text-light-500 outline-none transition-all hover:border-black focus:border-primary-light-500 dark:border-divider-dark dark:bg-background-dark-500 dark:text-text-dark-500 hover:dark:border-white focus:dark:border-primary-dark-500",
`flex flex-1 cursor-pointer flex-row items-center px-4 text-sm text-text-light-500 outline-none transition-all hover:border-black focus:border-primary-light-500 dark:text-text-dark-500 hover:dark:border-white focus:dark:border-primary-dark-500`,
buttonRoundness,
idpButtonAppearance,
`bg-background-light-400 dark:bg-background-dark-500`, // Keep background as fallback for non-glass themes
props.className,
)}
>
<div className="flex flex-1 items-center justify-between gap-4">
<div className="flex flex-1 flex-row items-center">
{props.children}
</div>
<div className="flex flex-1 flex-row items-center">{props.children}</div>
{formStatus.pending && <Loader2Icon className="h-4 w-4 animate-spin" />}
</div>
</button>
@@ -28,14 +28,16 @@ export async function completeIDP({
}) {
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="completeRegister.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="completeRegister.description" namespace="idp" />
</p>
</div>
<div className="w-full">
<RegisterFormIDPIncomplete
idpUserId={idpUserId}
idpId={idpId}
@@ -3,25 +3,19 @@ import { Alert, AlertType } from "../../alert";
import { DynamicTheme } from "../../dynamic-theme";
import { Translated } from "../../translated";
export async function linkingFailed(
branding?: BrandingSettings,
error?: string,
) {
export async function linkingFailed(branding?: BrandingSettings, error?: string) {
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="linkingError.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="linkingError.description" namespace="idp" />
</p>
{error && (
<div className="w-full">
{<Alert type={AlertType.ALERT}>{error}</Alert>}
</div>
)}
{error && <div className="w-full">{<Alert type={AlertType.ALERT}>{error}</Alert>}</div>}
</div>
<div className="w-full"></div>
</DynamicTheme>
);
}
@@ -11,19 +11,17 @@ export async function linkingSuccess(
) {
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="linkingSuccess.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="linkingSuccess.description" namespace="idp" />
</p>
</div>
<IdpSignin
userId={userId}
idpIntent={idpIntent}
requestId={requestId}
/>
<div className="w-full">
<IdpSignin userId={userId} idpIntent={idpIntent} requestId={requestId} />
</div>
</DynamicTheme>
);
@@ -13,12 +13,9 @@ export async function loginFailed(branding?: BrandingSettings, error?: string) {
<p className="ztdl-p">
<Translated i18nKey="loginError.description" namespace="idp" />
</p>
{error && (
<div className="w-full">
{<Alert type={AlertType.ALERT}>{error}</Alert>}
</div>
)}
{error && <div className="w-full">{<Alert type={AlertType.ALERT}>{error}</Alert>}</div>}
</div>
<div className="w-full"></div>
</DynamicTheme>
);
}
@@ -18,12 +18,10 @@ export async function loginSuccess(
<p className="ztdl-p">
<Translated i18nKey="loginSuccess.description" namespace="idp" />
</p>
</div>
<IdpSignin
userId={userId}
idpIntent={idpIntent}
requestId={requestId}
/>
<div className="w-full">
<IdpSignin userId={userId} idpIntent={idpIntent} requestId={requestId} />
</div>
</DynamicTheme>
);
+188
View File
@@ -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(<TextInput label="Email" />);
expect(screen.getByText("Email")).toBeTruthy();
});
it("should render input with placeholder", () => {
const { container } = render(<TextInput label="Username" placeholder="Enter username" />);
const input = container.querySelector("input");
expect(input?.placeholder).toBe("Enter username");
});
it("should render input with default value", () => {
const { container } = render(<TextInput label="Name" defaultValue="John Doe" />);
const input = container.querySelector("input");
expect(input?.defaultValue).toBe("John Doe");
});
it("should show required indicator when required", () => {
render(<TextInput label="Required Field" required />);
expect(screen.getByText(/\*/)).toBeTruthy();
});
});
describe("Input States", () => {
it("should render disabled state", () => {
const { container } = render(<TextInput label="Disabled" disabled />);
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(<TextInput label="Email" error="Invalid email" />);
expect(screen.getByText("Invalid email")).toBeTruthy();
});
it("should apply error styling when error is present", () => {
const { container } = render(<TextInput label="Email" error="Invalid" />);
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(<TextInput label="Email" success="Valid email" />);
expect(screen.getByText("Valid email")).toBeTruthy();
});
});
describe("Theme Integration", () => {
it("should apply theme-based roundness", () => {
const { container } = render(<TextInput label="Themed" />);
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(<TextInput label="Custom" roundness={customRoundness} />);
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(<TextInput label="Default" />);
const input1 = container1.querySelector("input");
const defaultClasses = input1?.className;
// Change theme
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
const { container: container2 } = render(<TextInput label="Full" />);
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(<TextInput label="Test" />);
const input = container.querySelector("input");
expect(input?.autocomplete).toBe("off");
});
it("should allow custom autocomplete", () => {
const { container } = render(<TextInput label="Email" autoComplete="email" />);
const input = container.querySelector("input");
expect(input?.autocomplete).toBe("email");
});
it("should pass through native input props", () => {
const { container } = render(<TextInput label="Test" maxLength={10} type="email" />);
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(<TextInput label="Test" />);
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(<TextInput label="Test" />);
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(<TextInput label="Test" />);
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(<TextInput label="Error Field" error="Error message" />);
const label = container.querySelector("label");
expect(label).toBeTruthy();
});
it("should have default label styling", () => {
const { container } = render(<TextInput label="Normal Field" />);
const label = container.querySelector("label");
expect(label).toBeTruthy();
expect(label?.className).toBeTruthy();
});
});
describe("Accessibility", () => {
it("should connect label to input", () => {
const { container } = render(<TextInput label="Accessible Input" />);
const label = container.querySelector("label");
const input = container.querySelector("input");
expect(label).toBeTruthy();
expect(input).toBeTruthy();
});
it("should show required indicator", () => {
const { container } = render(<TextInput label="UniqueRequiredField" required />);
const label = container.querySelector("label");
expect(label?.textContent).toContain("*");
});
});
});
+35 -28
View File
@@ -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>,
HTMLInputElement
> & {
export type TextInputProps = DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {
label: string;
suffix?: string;
placeholder?: string;
@@ -23,18 +15,27 @@ export type TextInputProps = DetailedHTMLProps<
disabled?: boolean;
onChange?: (value: ChangeEvent<HTMLInputElement>) => void;
onBlur?: (value: ChangeEvent<HTMLInputElement>) => 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<HTMLInputElement, TextInputProps>(
@@ -50,23 +51,23 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
success,
onChange,
onBlur,
roundness,
...props
},
ref,
) => {
// Use theme-based roundness if not explicitly provided
const actualRoundness = roundness || getDefaultInputRoundness();
return (
<label className="relative flex flex-col text-12px text-input-light-label dark:text-input-dark-label">
<span
className={`mb-1 leading-3 ${
error ? "text-warn-light-500 dark:text-warn-dark-500" : ""
}`}
>
<span className={`mb-1 leading-3 ${error ? "text-warn-light-500 dark:text-warn-dark-500" : ""}`}>
{label} {required && "*"}
</span>
<input
suppressHydrationWarning
ref={ref}
className={styles(!!error, !!disabled)}
className={styles(!!error, !!disabled, actualRoundness)}
defaultValue={defaultValue}
required={required}
disabled={disabled}
@@ -78,7 +79,13 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
/>
{suffix && (
<span className="absolute bottom-[22px] right-[3px] z-30 translate-y-1/2 transform rounded-sm bg-background-light-500 p-2 dark:bg-background-dark-500">
<span
className={clsx(
"absolute bottom-[22px] right-[3px] z-30 translate-y-1/2 transform bg-background-light-500 p-2 dark:bg-background-dark-500",
// Extract just the roundness part for the suffix (no padding)
actualRoundness.split(" ")[0], // Take only the first part (rounded-full, rounded-md, etc.)
)}
>
@{suffix}
</span>
)}
+23 -19
View File
@@ -2,24 +2,32 @@
import { setLanguageCookie } from "@/lib/cookies";
import { Lang, LANGS } from "@/lib/i18n";
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { getThemeConfig, getComponentRoundness, APPEARANCE_STYLES } from "@/lib/theme";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react";
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
// Helper function to get language switcher roundness from theme
function getLanguageSwitcherRoundness(): string {
return getComponentRoundness("button");
}
// Helper function to get card appearance styles for the language switcher
function getLanguageSwitcherCardAppearance(): string {
const themeConfig = getThemeConfig();
const appearance = APPEARANCE_STYLES[themeConfig.appearance];
return appearance?.card || "bg-black/5 dark:bg-white/5"; // Fallback to current styling
}
export function LanguageSwitcher() {
const currentLocale = useLocale();
const switcherRoundness = getLanguageSwitcherRoundness();
const cardAppearance = getLanguageSwitcherCardAppearance();
const [selected, setSelected] = useState(
LANGS.find((l) => l.code === currentLocale) || LANGS[0],
);
const [selected, setSelected] = useState(LANGS.find((l) => l.code === currentLocale) || LANGS[0]);
const router = useRouter();
@@ -37,21 +45,19 @@ export function LanguageSwitcher() {
<Listbox value={selected} onChange={handleChange}>
<ListboxButton
className={clsx(
"relative block w-full rounded-lg bg-black/5 py-1.5 pl-3 pr-8 text-left text-sm/6 text-black dark:bg-white/5 dark:text-white",
`relative block w-full py-1.5 pl-3 pr-8 text-left text-sm/6 text-black dark:text-white ${switcherRoundness}`,
cardAppearance,
"focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-white/25",
)}
>
{selected.name}
<ChevronDownIcon
className="group pointer-events-none absolute right-2.5 top-2.5 size-4"
aria-hidden="true"
/>
<ChevronDownIcon className="group pointer-events-none absolute right-2.5 top-2.5 size-4" aria-hidden="true" />
</ListboxButton>
<ListboxOptions
anchor="bottom"
transition
className={clsx(
"w-[var(--button-width)] rounded-xl border border-black/5 bg-background-light-500 p-1 [--anchor-gap:var(--spacing-1)] focus:outline-none dark:border-white/5 dark:bg-background-dark-500",
`w-[var(--button-width)] border border-black/5 bg-background-light-500 p-1 [--anchor-gap:var(--spacing-1)] focus:outline-none dark:border-white/5 dark:bg-background-dark-500 rounded-md`,
"transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0",
)}
>
@@ -59,12 +65,10 @@ export function LanguageSwitcher() {
<ListboxOption
key={lang.code}
value={lang}
className="group flex cursor-default select-none items-center gap-2 rounded-lg px-3 py-1.5 data-[focus]:bg-black/10 dark:data-[focus]:bg-white/10"
className={`group flex cursor-default select-none items-center gap-2 px-3 py-1.5 data-[focus]:bg-black/10 dark:data-[focus]:bg-white/10 ${switcherRoundness}`}
>
<CheckIcon className="invisible size-4 group-data-[selected]:visible" />
<div className="text-sm/6 text-black dark:text-white">
{lang.name}
</div>
<div className="text-sm/6 text-black dark:text-white">{lang.name}</div>
</ListboxOption>
))}
</ListboxOptions>
+11 -21
View File
@@ -2,10 +2,7 @@
import { idpTypeToSlug } from "@/lib/idp";
import { redirectToIdp } from "@/lib/server/idp";
import {
IdentityProvider,
IdentityProviderType,
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { IdentityProvider, IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { ReactNode, useActionState } from "react";
import { Alert } from "./alert";
import { SignInWithIdentityProviderProps } from "./idps/base-button";
@@ -23,6 +20,7 @@ export interface SignInWithIDPProps {
requestId?: string;
organization?: string;
linkOnly?: boolean;
showLabel?: boolean;
}
export function SignInWithIdp({
@@ -30,27 +28,21 @@ export function SignInWithIdp({
requestId,
organization,
linkOnly,
showLabel = true,
}: Readonly<SignInWithIDPProps>) {
const [state, action, _isPending] = useActionState(redirectToIdp, {});
const renderIDPButton = (idp: IdentityProvider, index: number) => {
const { id, name, type } = idp;
const components: Partial<
Record<
IdentityProviderType,
(props: SignInWithIdentityProviderProps) => ReactNode
>
> = {
const components: Partial<Record<IdentityProviderType, (props: SignInWithIdentityProviderProps) => ReactNode>> = {
[IdentityProviderType.APPLE]: SignInWithApple,
[IdentityProviderType.OAUTH]: SignInWithGeneric,
[IdentityProviderType.OIDC]: SignInWithGeneric,
[IdentityProviderType.GITHUB]: SignInWithGithub,
[IdentityProviderType.GITHUB_ES]: SignInWithGithub,
[IdentityProviderType.AZURE_AD]: SignInWithAzureAd,
[IdentityProviderType.GOOGLE]: (props) => (
<SignInWithGoogle {...props} e2e="google" />
),
[IdentityProviderType.GOOGLE]: (props) => <SignInWithGoogle {...props} e2e="google" />,
[IdentityProviderType.GITLAB]: SignInWithGitlab,
[IdentityProviderType.GITLAB_SELF_HOSTED]: SignInWithGitlab,
[IdentityProviderType.SAML]: SignInWithGeneric,
@@ -65,11 +57,7 @@ export function SignInWithIdp({
<input type="hidden" name="provider" value={idpTypeToSlug(type)} />
<input type="hidden" name="requestId" value={requestId} />
<input type="hidden" name="organization" value={organization} />
<input
type="hidden"
name="linkOnly"
value={linkOnly ? "true" : "false"}
/>
<input type="hidden" name="linkOnly" value={linkOnly ? "true" : "false"} />
<Component key={id} name={name} />
</form>
) : null;
@@ -77,9 +65,11 @@ export function SignInWithIdp({
return (
<div className="flex w-full flex-col space-y-2 text-sm">
<p className="ztdl-p text-center">
<Translated i18nKey="orSignInWith" namespace="idp" />
</p>
{showLabel && (
<p className="ztdl-p text-center">
<Translated i18nKey="orSignInWith" namespace="idp" />
</p>
)}
{!!identityProviders?.length && identityProviders?.map(renderIDPButton)}
{state?.error && (
<div className="py-4">
+52 -13
View File
@@ -1,16 +1,55 @@
import { clsx } from "clsx";
import { ThemeableProps } from "@/lib/themeUtils";
import { getThemeConfig, SPACING_STYLES, getComponentRoundness } from "@/lib/theme";
export const SkeletonCard = ({ isLoading }: { isLoading?: boolean }) => (
<div
className={clsx("rounded-2xl bg-gray-900/80 p-4", {
"relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent":
isLoading,
})}
>
<div className="space-y-3">
<div className="h-14 rounded-lg bg-gray-700" />
<div className="h-3 w-11/12 rounded-lg bg-gray-700" />
<div className="h-3 w-8/12 rounded-lg bg-gray-700" />
interface SkeletonCardProps extends ThemeableProps {
isLoading?: boolean;
}
// Helper function to get default card roundness from theme
function getDefaultCardRoundness(): string {
return getComponentRoundness("card");
}
// Helper function to get default spacing from centralized theme system
function getDefaultSpacing(): string {
const themeConfig = getThemeConfig();
return SPACING_STYLES[themeConfig.spacing].spacing;
}
// Helper function to get default padding from centralized theme system
function getDefaultPadding(): string {
const themeConfig = getThemeConfig();
return SPACING_STYLES[themeConfig.spacing].padding;
}
export const SkeletonCard = ({
isLoading,
roundness, // Will use theme default if not provided
spacing, // Will use theme default if not provided
padding, // Will use theme default if not provided
}: SkeletonCardProps) => {
// Use theme-based values if not explicitly provided
const actualRoundness = roundness || getDefaultCardRoundness();
const actualSpacing = spacing || getDefaultSpacing();
const actualPadding = padding || getDefaultPadding();
return (
<div
className={clsx(
"bg-gray-900/80",
actualPadding,
{
"relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent":
isLoading,
},
actualRoundness, // Apply the full roundness classes directly
)}
>
<div className={actualSpacing}>
<div className={clsx("h-14 bg-gray-700", actualRoundness.split(" ")[0])} />
<div className={clsx("h-3 w-11/12 bg-gray-700", actualRoundness.split(" ")[0])} />
<div className={clsx("h-3 w-8/12 bg-gray-700", actualRoundness.split(" ")[0])} />
</div>
</div>
</div>
);
);
};
@@ -0,0 +1,69 @@
"use client";
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { getThemeConfig, getComponentRoundness, APPEARANCE_STYLES } from "@/lib/theme";
function getThemeToggleRoundness() {
return getComponentRoundness("themeSwitch");
}
// Helper function to get card appearance styles for the theme switch wrapper
function getThemeSwitchCardAppearance(): string {
const themeConfig = getThemeConfig();
const appearance = APPEARANCE_STYLES[themeConfig.appearance];
return appearance?.card || "bg-black/5 dark:bg-white/5"; // Fallback to current styling
}
// Helper function to get selected button styling for clear visibility
function getSelectedButtonStyle(isSelected: boolean): string {
const themeConfig = getThemeConfig();
if (!isSelected) {
return "text-gray-400 hover:text-gray-300 dark:text-gray-500 dark:hover:text-gray-400";
}
// Selected state styling based on appearance theme
switch (themeConfig.appearance) {
case "glass":
return "bg-white/30 dark:bg-black/30 text-gray-900 dark:text-white shadow-lg backdrop-blur-sm border border-white/40 dark:border-white/20";
case "material":
return "bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-md";
case "flat":
default:
return "bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700";
}
}
export default function ThemeSwitch() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
const toggleRoundness = getThemeToggleRoundness();
const cardAppearance = getThemeSwitchCardAppearance();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<div className={`flex space-x-1 p-1 ${toggleRoundness} ${cardAppearance}`}>
<button
className={`w-8 h-8 flex flex-row items-center justify-center ${toggleRoundness} transition-colors ${getSelectedButtonStyle(theme === "light")}`}
onClick={() => setTheme("light")}
aria-label="Switch to light mode"
>
<SunIcon className="h-5 w-5" />
</button>
<button
className={`w-8 h-8 flex flex-row items-center justify-center ${toggleRoundness} transition-colors ${getSelectedButtonStyle(theme === "dark")}`}
onClick={() => setTheme("dark")}
aria-label="Switch to dark mode"
>
<MoonIcon className="h-4 w-4" />
</button>
</div>
);
}
@@ -3,6 +3,7 @@
import { setTheme } from "@/helpers/colors";
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
import { ReactNode, useEffect } from "react";
import { useTheme } from "next-themes";
type Props = {
branding: BrandingSettings | undefined;
@@ -10,9 +11,35 @@ type Props = {
};
export const ThemeWrapper = ({ children, branding }: Props) => {
const { setTheme: setNextTheme } = useTheme();
useEffect(() => {
setTheme(document, branding);
}, [branding]);
// Handle branding themeMode to force specific theme
useEffect(() => {
if (branding?.themeMode !== undefined) {
// Based on the proto definition:
// THEME_MODE_UNSPECIFIED = 0
// THEME_MODE_AUTO = 1
// THEME_MODE_LIGHT = 2
// THEME_MODE_DARK = 3
switch (branding.themeMode) {
case 2: // THEME_MODE_LIGHT
setNextTheme("light");
break;
case 3: // THEME_MODE_DARK
setNextTheme("dark");
break;
case 1: // THEME_MODE_AUTO
case 0: // THEME_MODE_UNSPECIFIED
default:
setNextTheme("system");
break;
}
}
}, [branding?.themeMode, setNextTheme]);
return <div>{children}</div>;
};
-44
View File
@@ -1,44 +0,0 @@
"use client";
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function Theme() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState<boolean>(false);
const isDark = resolvedTheme === "dark";
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<div
className={`relative grid h-fit grid-cols-2 rounded-full border border-divider-light p-1 dark:border-divider-dark`}
>
<button
className={`flex h-8 w-8 flex-row items-center justify-center rounded-full transition-all hover:opacity-100 ${
isDark ? "bg-black/10 dark:bg-white/10" : "opacity-60"
}`}
onClick={() => setTheme("dark")}
>
<MoonIcon className="h-4 w-4 flex-shrink-0 rounded-full text-xl" />
</button>
<button
className={`flex h-8 w-8 flex-row items-center justify-center rounded-full transition-all hover:opacity-100 ${
!isDark ? "bg-black/10 dark:bg-white/10" : "opacity-60"
}`}
onClick={() => setTheme("light")}
>
<SunIcon className="h-6 w-6 flex-shrink-0 rounded-full text-xl" />
</button>
</div>
);
}
+12 -16
View File
@@ -1,6 +1,12 @@
import { Avatar } from "@/components/avatar";
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import { getComponentRoundness } from "@/lib/theme";
// Helper function to get user avatar container roundness from theme
function getUserAvatarRoundness(): string {
return getComponentRoundness("avatarContainer");
}
type Props = {
loginName?: string;
@@ -9,13 +15,9 @@ type Props = {
searchParams?: Record<string | number | symbol, string | undefined>;
};
export function UserAvatar({
loginName,
displayName,
showDropdown,
searchParams,
}: Props) {
export function UserAvatar({ loginName, displayName, showDropdown, searchParams }: Props) {
const params = new URLSearchParams({});
const userAvatarRoundness = getUserAvatarRoundness();
if (searchParams?.sessionId) {
params.set("sessionId", searchParams.sessionId);
@@ -34,22 +36,16 @@ export function UserAvatar({
}
return (
<div className="flex h-full flex-row items-center rounded-full border p-[1px] dark:border-white/20">
<div className={`flex h-full flex-row items-center border p-[1px] dark:border-white/20 ${userAvatarRoundness}`}>
<div>
<Avatar
size="small"
name={displayName ?? loginName ?? ""}
loginName={loginName ?? ""}
/>
<Avatar size="small" name={displayName ?? loginName ?? ""} loginName={loginName ?? ""} />
</div>
<span className="ml-4 max-w-[250px] overflow-hidden text-ellipsis pr-4 text-14px">
{loginName}
</span>
<span className="ml-4 max-w-[250px] overflow-hidden text-ellipsis pr-4 text-14px">{loginName}</span>
<span className="flex-grow"></span>
{showDropdown && (
<Link
href={"/accounts?" + params}
className="ml-4 mr-1 flex items-center justify-center rounded-full p-1 transition-all hover:bg-black/10 dark:hover:bg-white/10"
className={`ml-4 mr-1 flex items-center justify-center p-1 transition-all hover:bg-black/10 dark:hover:bg-white/10 ${userAvatarRoundness}`}
>
<ChevronDownIcon className="h-4 w-4" />
</Link>