mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-14 01:45:12 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user