mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-26 03:26:29 +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:
75
apps/login/.env.theme.example
Normal file
75
apps/login/.env.theme.example
Normal file
@@ -0,0 +1,75 @@
|
||||
# ==============================================
|
||||
# ZITADEL LOGIN THEME CONFIGURATION
|
||||
# ==============================================
|
||||
# This file contains all available theme options for customizing the login experience.
|
||||
# Copy the variables you want to customize to your .env.local file.
|
||||
|
||||
# Theme Roundness Options
|
||||
# ----------------------
|
||||
# Controls the border radius of UI elements
|
||||
# Options: "edgy" | "mid" | "full"
|
||||
# - edgy: Sharp corners (rounded-none)
|
||||
# - mid: Medium rounded corners (rounded-lg)
|
||||
# - full: Fully rounded corners (rounded-full)
|
||||
NEXT_PUBLIC_THEME_ROUNDNESS=mid
|
||||
|
||||
# Layout Options
|
||||
# --------------
|
||||
# Controls the overall layout structure
|
||||
# Options: "side-by-side" | "top-to-bottom"
|
||||
# - side-by-side: Brand section on left, form on right (desktop)
|
||||
# - top-to-bottom: Brand section on top, form below
|
||||
NEXT_PUBLIC_THEME_LAYOUT=side-by-side
|
||||
|
||||
# Spacing Options
|
||||
# ---------------
|
||||
# Controls spacing and padding throughout the interface
|
||||
# Options: "regular" | "compact"
|
||||
# - regular: Standard spacing with comfortable padding (p-6, space-y-6)
|
||||
# - compact: Tighter spacing for information-dense layouts (p-4, space-y-4)
|
||||
NEXT_PUBLIC_THEME_SPACING=regular
|
||||
|
||||
# Appearance Options
|
||||
# ------------------
|
||||
# Complete design philosophies
|
||||
# Options: "flat" | "material"
|
||||
# - flat: Minimal flat design with cards matching background color, subtle borders, and normal typography
|
||||
# - material: Material Design inspired with elevated cards, proper contrast, and medium typography
|
||||
NEXT_PUBLIC_THEME_APPEARANCE=flat
|
||||
|
||||
# Background Image
|
||||
# ---------------
|
||||
# Path to a background image (optional)
|
||||
# Can be a relative path from /public or an absolute URL
|
||||
# Example: NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/login-bg.jpg
|
||||
# NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=
|
||||
|
||||
# ==============================================
|
||||
# EXAMPLE COMBINATIONS
|
||||
# ==============================================
|
||||
|
||||
# Modern Tech Startup
|
||||
# NEXT_PUBLIC_THEME_ROUNDNESS=full
|
||||
# NEXT_PUBLIC_THEME_LAYOUT=side-by-side
|
||||
# NEXT_PUBLIC_THEME_SPACING=regular
|
||||
# NEXT_PUBLIC_THEME_APPEARANCE=material
|
||||
# NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/tech-bg.jpg
|
||||
|
||||
# Corporate Banking
|
||||
# NEXT_PUBLIC_THEME_ROUNDNESS=edgy
|
||||
# NEXT_PUBLIC_THEME_LAYOUT=top-to-bottom
|
||||
# NEXT_PUBLIC_THEME_SPACING=regular
|
||||
# NEXT_PUBLIC_THEME_APPEARANCE=material
|
||||
|
||||
# Minimal SaaS
|
||||
# NEXT_PUBLIC_THEME_ROUNDNESS=mid
|
||||
# NEXT_PUBLIC_THEME_LAYOUT=side-by-side
|
||||
# NEXT_PUBLIC_THEME_SPACING=compact
|
||||
# NEXT_PUBLIC_THEME_APPEARANCE=flat
|
||||
|
||||
# Creative Agency
|
||||
# NEXT_PUBLIC_THEME_ROUNDNESS=full
|
||||
# NEXT_PUBLIC_THEME_LAYOUT=top-to-bottom
|
||||
# NEXT_PUBLIC_THEME_SPACING=regular
|
||||
# NEXT_PUBLIC_THEME_APPEARANCE=material
|
||||
# NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/creative-bg.jpg
|
||||
212
apps/login/THEME_ARCHITECTURE.md
Normal file
212
apps/login/THEME_ARCHITECTURE.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Current Theme System Architecture
|
||||
|
||||
Our theme system provides a simple, environment variable-driven approach for consistent component styling and responsive layout switching.
|
||||
|
||||
## 🏗️ **Current Implementation**
|
||||
|
||||
### **Environment Variable Configuration**
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXT_PUBLIC_THEME_ROUNDNESS=mid # edgy | mid | full
|
||||
NEXT_PUBLIC_THEME_LAYOUT=side-by-side # side-by-side | top-to-bottom
|
||||
NEXT_PUBLIC_THEME_APPEARANCE=material # flat | material
|
||||
NEXT_PUBLIC_THEME_SPACING=regular # regular | compact
|
||||
```
|
||||
|
||||
### **Core Theme Functions**
|
||||
|
||||
```tsx
|
||||
// Server-safe theme configuration
|
||||
import { getThemeConfig, getComponentRoundness } from "@/lib/theme";
|
||||
|
||||
// Get full theme configuration
|
||||
const themeConfig = getThemeConfig();
|
||||
// Returns: { roundness: 'mid', layout: 'side-by-side', appearance: 'material', ... }
|
||||
|
||||
// Get component-specific styling
|
||||
const buttonRoundness = getComponentRoundness("button");
|
||||
// Returns: "rounded-md" (CSS class)
|
||||
```
|
||||
|
||||
### **Responsive Layout Hook**
|
||||
|
||||
```tsx
|
||||
// Client-side responsive layout detection
|
||||
import { useResponsiveLayout } from "@/lib/theme-hooks";
|
||||
|
||||
function MyComponent() {
|
||||
const { isSideBySide, isResponsiveOverride } = useResponsiveLayout();
|
||||
|
||||
return <div className={isSideBySide ? "flex" : "flex-col"}>{/* Layout adapts automatically */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 **Component Integration Patterns**
|
||||
|
||||
### **Pattern 1: Direct Function Calls** (Current Standard)
|
||||
|
||||
```tsx
|
||||
import { getComponentRoundness } from "@/lib/theme";
|
||||
|
||||
export function Button({ children, variant = "primary" }) {
|
||||
const roundness = getComponentRoundness("button");
|
||||
|
||||
return (
|
||||
<button className={`${roundness} px-4 py-2 ${variant === "primary" ? "bg-blue-500" : "bg-gray-500"}`}>{children}</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### **Pattern 2: Component-Specific Helper Functions**
|
||||
|
||||
```tsx
|
||||
import { getComponentRoundness } from "@/lib/theme";
|
||||
|
||||
// Helper function for UserAvatar
|
||||
function getUserAvatarRoundness(): string {
|
||||
return getComponentRoundness("avatarContainer");
|
||||
}
|
||||
|
||||
export function UserAvatar({ loginName, displayName }) {
|
||||
const roundness = getUserAvatarRoundness();
|
||||
|
||||
return <div className={`flex border p-1 ${roundness}`}>{/* Avatar content */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### **Pattern 3: Theme-Aware Layout Components**
|
||||
|
||||
```tsx
|
||||
import { useResponsiveLayout } from "@/lib/theme-hooks";
|
||||
|
||||
export function DynamicTheme({ children, branding }) {
|
||||
const { isSideBySide } = useResponsiveLayout();
|
||||
|
||||
return (
|
||||
<ThemeWrapper branding={branding}>
|
||||
{isSideBySide ? (
|
||||
// Side-by-side layout for desktop
|
||||
<div className="flex max-w-[1200px]">
|
||||
<div className="w-1/2">{/* Left content */}</div>
|
||||
<div className="w-1/2">{/* Right content */}</div>
|
||||
</div>
|
||||
) : (
|
||||
// Top-to-bottom layout for mobile
|
||||
<div className="flex-col max-w-[440px]">{children}</div>
|
||||
)}
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 **Theme Configuration Structure**
|
||||
|
||||
### **Component Roundness Mapping**
|
||||
|
||||
```tsx
|
||||
export interface ComponentRoundnessConfig {
|
||||
card: ThemeRoundness; // "rounded-lg" | "rounded-none" | "rounded-3xl"
|
||||
button: ThemeRoundness; // "rounded-md" | "rounded-none" | "rounded-full"
|
||||
input: ThemeRoundness; // "rounded-md" | "rounded-none" | "rounded-full pl-4"
|
||||
image: ThemeRoundness; // "rounded-lg" | "rounded-none" | "rounded-full"
|
||||
avatar: ThemeRoundness; // "rounded-lg" | "rounded-none" | "rounded-full"
|
||||
avatarContainer: ThemeRoundness; // "rounded-md" | "rounded-none" | "rounded-full"
|
||||
themeSwitch: ThemeRoundness; // "rounded-md" | "rounded-none" | "rounded-full"
|
||||
}
|
||||
```
|
||||
|
||||
### **Responsive Layout Logic**
|
||||
|
||||
```tsx
|
||||
// Automatic layout switching based on screen size
|
||||
const isSideBySide = themeConfig.layout === "side-by-side" && !isMdOrSmaller;
|
||||
|
||||
// md breakpoint: 768px (Tailwind default)
|
||||
// Below 768px: Always use top-to-bottom layout
|
||||
// Above 768px: Use configured layout (side-by-side or top-to-bottom)
|
||||
```
|
||||
|
||||
## <20> **File Structure**
|
||||
|
||||
```
|
||||
src/lib/
|
||||
├── theme.ts # Server-safe theme functions
|
||||
├── theme-hooks.ts # Client-side responsive hooks
|
||||
└── themeUtils.tsx # Legacy utility functions
|
||||
|
||||
src/components/
|
||||
├── dynamic-theme.tsx # Main responsive layout component
|
||||
├── theme-wrapper.tsx # Theme application wrapper
|
||||
├── button.tsx # Example themed component
|
||||
├── card.tsx # Example themed component
|
||||
└── user-avatar.tsx # Example themed component
|
||||
```
|
||||
|
||||
## <20> **Usage Examples**
|
||||
|
||||
### **Adding Theme Support to New Components**
|
||||
|
||||
```tsx
|
||||
import { getComponentRoundness } from "@/lib/theme";
|
||||
|
||||
export function NewComponent() {
|
||||
// Get theme-appropriate styling
|
||||
const roundness = getComponentRoundness("card");
|
||||
|
||||
return <div className={`p-4 ${roundness} bg-white`}>{/* Component content */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### **Using Responsive Layout**
|
||||
|
||||
```tsx
|
||||
import { useResponsiveLayout } from "@/lib/theme-hooks";
|
||||
|
||||
export function ResponsiveComponent() {
|
||||
const { isSideBySide } = useResponsiveLayout();
|
||||
|
||||
return <div className={isSideBySide ? "text-left" : "text-center"}>Content adapts to layout</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### **Page Layout Integration**
|
||||
|
||||
```tsx
|
||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>Login Title</h1>
|
||||
<p>Description text</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## ⚡ **Key Features**
|
||||
|
||||
1. **Environment Variable Configuration**: Simple `.env.local` setup
|
||||
2. **Server-Safe Functions**: Work in both SSR and client components
|
||||
3. **Responsive Layout Switching**: Automatic mobile/desktop adaptation
|
||||
4. **Component-Specific Styling**: Different roundness per component type
|
||||
5. **Type Safety**: Full TypeScript support
|
||||
6. **Zero Runtime Dependencies**: No context providers or complex state
|
||||
7. **SSR Compatible**: No hydration mismatches
|
||||
|
||||
## 🔄 **Architecture Benefits**
|
||||
|
||||
- **Simple**: Environment variables → CSS classes
|
||||
- **Fast**: No runtime theme calculations or context switching
|
||||
- **Reliable**: Server-side rendering compatible
|
||||
- **Scalable**: Easy to add new theme properties
|
||||
- **Maintainable**: Clear separation between layout and styling concerns
|
||||
|
||||
This architecture provides a solid foundation for environment-driven theming while keeping the implementation simple and performant!
|
||||
138
apps/login/THEME_CUSTOMIZATION.md
Normal file
138
apps/login/THEME_CUSTOMIZATION.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Login App Theme Customization Guide
|
||||
|
||||
This guide helps you customize the appearance of your ZITADEL login application for a personalized user experience.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy the theme variables you want to customize from `.env.theme.example` to your `.env.local` file
|
||||
2. Restart your application
|
||||
3. Your theme changes will be applied automatically
|
||||
|
||||
## Theme Options
|
||||
|
||||
### 🔄 Roundness (`NEXT_PUBLIC_THEME_ROUNDNESS`)
|
||||
|
||||
Controls how rounded the UI elements appear:
|
||||
|
||||
- **`edgy`** - Sharp, rectangular corners (modern tech, corporate)
|
||||
- **`mid`** - Medium rounded corners (balanced, professional)
|
||||
- **`full`** - Fully rounded corners (friendly, approachable)
|
||||
|
||||
### 📱 Layout (`NEXT_PUBLIC_THEME_LAYOUT`)
|
||||
|
||||
Controls the overall page structure:
|
||||
|
||||
- **`side-by-side`** - Brand section on left, form on right (desktop view)
|
||||
- **`top-to-bottom`** - Brand section on top, form below (mobile-first)
|
||||
|
||||
### 📏 Spacing (`NEXT_PUBLIC_THEME_SPACING`)
|
||||
|
||||
Controls spacing and padding throughout the interface:
|
||||
|
||||
- **`regular`** - Standard spacing with comfortable padding (p-6, space-y-6)
|
||||
- **`compact`** - Tighter spacing for information-dense layouts (p-4, space-y-4)
|
||||
|
||||
### 🎨 Appearance (`NEXT_PUBLIC_THEME_APPEARANCE`)
|
||||
|
||||
Complete design philosophies:
|
||||
|
||||
- **`flat`** - Minimal flat design with cards matching background color, subtle borders, and normal typography
|
||||
- **`material`** - Material Design inspired with elevated cards, proper contrast, and medium typography
|
||||
|
||||
### 🖼️ Background Image (`NEXT_PUBLIC_THEME_BACKGROUND_IMAGE`)
|
||||
|
||||
Add a custom background image:
|
||||
|
||||
- Use local images: `/images/my-background.jpg` (place in `public/images/`)
|
||||
- Use external URLs: `https://example.com/background.jpg`
|
||||
- Leave empty for solid color backgrounds
|
||||
|
||||
## Example Configurations
|
||||
|
||||
### Tech Startup
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_THEME_ROUNDNESS=full
|
||||
NEXT_PUBLIC_THEME_LAYOUT=side-by-side
|
||||
NEXT_PUBLIC_THEME_SPACING=regular
|
||||
NEXT_PUBLIC_THEME_APPEARANCE=material
|
||||
NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/tech-gradient.jpg
|
||||
```
|
||||
|
||||
### Corporate Bank
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_THEME_ROUNDNESS=edgy
|
||||
NEXT_PUBLIC_THEME_LAYOUT=top-to-bottom
|
||||
NEXT_PUBLIC_THEME_SPACING=regular
|
||||
NEXT_PUBLIC_THEME_APPEARANCE=material
|
||||
```
|
||||
|
||||
### Minimal SaaS
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_THEME_ROUNDNESS=mid
|
||||
NEXT_PUBLIC_THEME_LAYOUT=side-by-side
|
||||
NEXT_PUBLIC_THEME_SPACING=compact
|
||||
NEXT_PUBLIC_THEME_APPEARANCE=flat
|
||||
```
|
||||
|
||||
### Creative Agency
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_THEME_ROUNDNESS=full
|
||||
NEXT_PUBLIC_THEME_LAYOUT=top-to-bottom
|
||||
NEXT_PUBLIC_THEME_SPACING=regular
|
||||
NEXT_PUBLIC_THEME_APPEARANCE=material
|
||||
NEXT_PUBLIC_THEME_BACKGROUND_IMAGE=/images/creative-workspace.jpg
|
||||
```
|
||||
|
||||
### Flat Design App
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_THEME_ROUNDNESS=mid
|
||||
NEXT_PUBLIC_THEME_LAYOUT=side-by-side
|
||||
NEXT_PUBLIC_THEME_SPACING=compact
|
||||
NEXT_PUBLIC_THEME_APPEARANCE=flat
|
||||
```
|
||||
|
||||
### Material Design System
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_THEME_ROUNDNESS=mid
|
||||
NEXT_PUBLIC_THEME_LAYOUT=side-by-side
|
||||
NEXT_PUBLIC_THEME_SPACING=regular
|
||||
NEXT_PUBLIC_THEME_APPEARANCE=material
|
||||
```
|
||||
|
||||
## Advanced Customization
|
||||
|
||||
For more detailed customization beyond these presets, you can:
|
||||
|
||||
1. **Custom CSS**: Add your own CSS files in the `src/styles/` directory
|
||||
2. **Component Override**: Modify the theme configuration in `src/lib/theme.ts`
|
||||
3. **Custom Appearances**: Add new appearance options to the `APPEARANCE_STYLES` or `SPACING_STYLES` objects
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Theme not applying
|
||||
|
||||
- Ensure you're using `NEXT_PUBLIC_` prefix for all theme variables (`NEXT_PUBLIC_THEME_ROUNDNESS`, `NEXT_PUBLIC_THEME_SPACING`, `NEXT_PUBLIC_THEME_APPEARANCE`, etc.)
|
||||
- Restart your development server after changing environment variables
|
||||
- Check that your `.env.local` file is in the root of the login app directory
|
||||
|
||||
### Background image not loading
|
||||
|
||||
- Verify the image path is correct
|
||||
- For external URLs, ensure the domain is accessible
|
||||
- Check browser console for any loading errors
|
||||
|
||||
### Layout issues on mobile
|
||||
|
||||
- Test your theme on different screen sizes
|
||||
- The `top-to-bottom` layout is generally more mobile-friendly
|
||||
- Some combinations work better on certain screen sizes
|
||||
|
||||
## Support
|
||||
|
||||
For additional customization needs or questions, please refer to the ZITADEL documentation or community forums.
|
||||
10
apps/login/next-env-vars.d.ts
vendored
10
apps/login/next-env-vars.d.ts
vendored
@@ -34,5 +34,15 @@ declare namespace NodeJS {
|
||||
* `CUSTOM_REQUEST_HEADERS=Host:http://zitadel-internal:8080`
|
||||
*/
|
||||
CUSTOM_REQUEST_HEADERS?: string;
|
||||
|
||||
/**
|
||||
* The base path the app is served from, e.g. /ui/v2/login
|
||||
*/
|
||||
NEXT_PUBLIC_BASE_PATH: string;
|
||||
|
||||
/**
|
||||
* Optional: The application name shown in the login and invite emails
|
||||
*/
|
||||
NEXT_PUBLIC_APPLICATION_NAME?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import { SessionsList } from "@/components/sessions-list";
|
||||
import { Translated } from "@/components/translated";
|
||||
import { getAllSessionCookieIds } from "@/lib/cookies";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getDefaultOrg,
|
||||
listSessions,
|
||||
} from "@/lib/zitadel";
|
||||
import { getBrandingSettings, getDefaultOrg, listSessions } from "@/lib/zitadel";
|
||||
import { UserPlusIcon } from "@heroicons/react/24/outline";
|
||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||
import { Metadata } from "next";
|
||||
@@ -18,7 +14,7 @@ import Link from "next/link";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("accounts");
|
||||
return { title: t('title')};
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
async function loadSessions({ serviceUrl }: { serviceUrl: string }) {
|
||||
@@ -36,9 +32,7 @@ async function loadSessions({ serviceUrl }: { serviceUrl: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const requestId = searchParams?.requestId;
|
||||
@@ -76,14 +70,16 @@ export default async function Page(props: {
|
||||
|
||||
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="title" namespace="accounts" />
|
||||
</h1>
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="description" namespace="accounts" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex w-full flex-col space-y-2">
|
||||
<SessionsList sessions={sessions} requestId={requestId} />
|
||||
<Link href={`/loginname?` + params}>
|
||||
|
||||
@@ -26,12 +26,10 @@ import { redirect } from "next/navigation";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("authenticator");
|
||||
return { title: t('title')};
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const { loginName, requestId, organization, sessionId } = searchParams;
|
||||
@@ -59,8 +57,7 @@ export default async function Page(props: {
|
||||
userId,
|
||||
}).then((methods) => {
|
||||
return getUserByID({ serviceUrl, userId }).then((user) => {
|
||||
const humanUser =
|
||||
user.user?.type.case === "human" ? user.user?.type.value : undefined;
|
||||
const humanUser = user.user?.type.case === "human" ? user.user?.type.value : undefined;
|
||||
|
||||
return {
|
||||
factors: session?.factors,
|
||||
@@ -73,10 +70,7 @@ export default async function Page(props: {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSessionByLoginname(
|
||||
loginName?: string,
|
||||
organization?: string,
|
||||
) {
|
||||
async function loadSessionByLoginname(loginName?: string, organization?: string) {
|
||||
return loadMostRecentSession({
|
||||
serviceUrl,
|
||||
sessionParams: {
|
||||
@@ -99,11 +93,7 @@ export default async function Page(props: {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!sessionWithData ||
|
||||
!sessionWithData.factors ||
|
||||
!sessionWithData.factors.user
|
||||
) {
|
||||
if (!sessionWithData || !sessionWithData.factors || !sessionWithData.factors.user) {
|
||||
return (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
@@ -122,9 +112,7 @@ export default async function Page(props: {
|
||||
});
|
||||
|
||||
// check if user was verified recently
|
||||
const isUserVerified = await checkUserVerification(
|
||||
sessionWithData.factors.user?.id,
|
||||
);
|
||||
const isUserVerified = await checkUserVerification(sessionWithData.factors.user?.id);
|
||||
|
||||
if (!isUserVerified) {
|
||||
const params = new URLSearchParams({
|
||||
@@ -138,10 +126,7 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
if (organization || sessionWithData.factors.user.organizationId) {
|
||||
params.append(
|
||||
"organization",
|
||||
organization ?? (sessionWithData.factors.user.organizationId as string),
|
||||
);
|
||||
params.append("organization", organization ?? (sessionWithData.factors.user.organizationId as string));
|
||||
}
|
||||
|
||||
redirect(`/verify?` + params);
|
||||
@@ -173,7 +158,7 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="title" namespace="authenticator" />
|
||||
</h1>
|
||||
@@ -188,7 +173,9 @@ export default async function Page(props: {
|
||||
showDropdown
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{loginSettings && (
|
||||
<ChooseAuthenticatorToSetup
|
||||
authMethods={sessionWithData.authMethods}
|
||||
|
||||
@@ -2,17 +2,11 @@ import { ConsentScreen } from "@/components/consent";
|
||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { Translated } from "@/components/translated";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getDefaultOrg,
|
||||
getDeviceAuthorizationRequest,
|
||||
} from "@/lib/zitadel";
|
||||
import { getBrandingSettings, getDefaultOrg, getDeviceAuthorizationRequest } from "@/lib/zitadel";
|
||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const userCode = searchParams?.user_code;
|
||||
@@ -70,13 +64,9 @@ export default async function Page(props: {
|
||||
|
||||
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="request.title"
|
||||
namespace="device"
|
||||
data={{ appName: deviceAuthorizationRequest?.appName }}
|
||||
/>
|
||||
<Translated i18nKey="request.title" namespace="device" data={{ appName: deviceAuthorizationRequest?.appName }} />
|
||||
</h1>
|
||||
|
||||
<p className="ztdl-p">
|
||||
@@ -86,7 +76,9 @@ export default async function Page(props: {
|
||||
data={{ appName: deviceAuthorizationRequest?.appName }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<ConsentScreen
|
||||
deviceAuthorizationRequestId={deviceAuthorizationRequest?.id}
|
||||
scope={deviceAuthorizationRequest.scope}
|
||||
|
||||
@@ -10,12 +10,10 @@ import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("device");
|
||||
return { title: t('usercode.title')};
|
||||
return { title: t("usercode.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const userCode = searchParams?.user_code;
|
||||
@@ -41,13 +39,16 @@ export default async function Page(props: {
|
||||
|
||||
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="usercode.title" namespace="device" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="usercode.description" namespace="device" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<DeviceCodeForm userCode={userCode}></DeviceCodeForm>
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
|
||||
@@ -4,12 +4,7 @@ import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { Translated } from "@/components/translated";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getLoginSettings,
|
||||
getUserByID,
|
||||
listAuthenticationMethodTypes,
|
||||
} from "@/lib/zitadel";
|
||||
import { getBrandingSettings, getLoginSettings, getUserByID, listAuthenticationMethodTypes } from "@/lib/zitadel";
|
||||
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { headers } from "next/headers";
|
||||
@@ -74,10 +69,13 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="loginError.title" namespace="idp" />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Alert type={AlertType.ALERT}>
|
||||
<Translated i18nKey="loginError.description" namespace="idp" />
|
||||
</Alert>
|
||||
|
||||
@@ -293,13 +293,16 @@ export default async function Page(props: {
|
||||
if (newUser) {
|
||||
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="registerSuccess.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="registerSuccess.description" namespace="idp" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<IdpSignin userId={newUser.userId} idpIntent={{ idpIntentId: id, idpIntentToken: token }} requestId={requestId} />
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
|
||||
@@ -38,18 +38,17 @@ export default async function Page(props: {
|
||||
// return login failed if no linking or creation is allowed and no user was found
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="title" namespace="ldap" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="description" namespace="ldap" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LDAPUsernamePasswordForm
|
||||
idpId={idpId}
|
||||
link={link === "true"}
|
||||
></LDAPUsernamePasswordForm>
|
||||
<div className="w-full">
|
||||
<LDAPUsernamePasswordForm idpId={idpId} link={link === "true"}></LDAPUsernamePasswordForm>
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,10 @@ import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("idp");
|
||||
return { title: t('title')};
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const requestId = searchParams?.requestId;
|
||||
@@ -37,19 +35,22 @@ export default async function Page(props: {
|
||||
|
||||
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="title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="description" namespace="idp" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{!!identityProviders?.length && (
|
||||
<SignInWithIdp
|
||||
identityProviders={identityProviders}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
showLabel={false}
|
||||
></SignInWithIdp>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import "@/styles/globals.scss";
|
||||
|
||||
import { BackgroundWrapper } from "@/components/background-wrapper";
|
||||
import { LanguageProvider } from "@/components/language-provider";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { Skeleton } from "@/components/skeleton";
|
||||
import { Theme } from "@/components/theme";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Lato } from "next/font/google";
|
||||
import { ReactNode, Suspense } from "react";
|
||||
import ThemeSwitch from "@/components/theme-switch";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
@@ -30,7 +31,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
||||
<Tooltip.Provider>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
<BackgroundWrapper
|
||||
className={`relative flex min-h-screen flex-col justify-center bg-background-light-600 dark:bg-background-dark-600`}
|
||||
>
|
||||
<div className="relative mx-auto w-full max-w-[440px] py-8">
|
||||
@@ -38,24 +39,24 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
||||
<div className="h-40"></div>
|
||||
</Skeleton>
|
||||
<div className="flex flex-row items-center justify-end space-x-4 py-4">
|
||||
<Theme />
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BackgroundWrapper>
|
||||
}
|
||||
>
|
||||
<LanguageProvider>
|
||||
<div
|
||||
<BackgroundWrapper
|
||||
className={`relative flex min-h-screen flex-col justify-center bg-background-light-600 dark:bg-background-dark-600`}
|
||||
>
|
||||
<div className="relative mx-auto w-full max-w-[440px] py-8">
|
||||
{children}
|
||||
<div className="flex flex-row items-center justify-end space-x-4 py-4">
|
||||
<div className="relative mx-auto w-full max-w-[1100px] py-8">
|
||||
<div>{children}</div>
|
||||
<div className="flex flex-row items-center justify-end space-x-4 py-4 px-4 md:px-8 max-w-[440px] mx-auto md:max-w-full">
|
||||
<LanguageSwitcher />
|
||||
<Theme />
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BackgroundWrapper>
|
||||
</LanguageProvider>
|
||||
</Suspense>
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -3,12 +3,7 @@ import { SignInWithIdp } from "@/components/sign-in-with-idp";
|
||||
import { Translated } from "@/components/translated";
|
||||
import { UsernameForm } from "@/components/username-form";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import {
|
||||
getActiveIdentityProviders,
|
||||
getBrandingSettings,
|
||||
getDefaultOrg,
|
||||
getLoginSettings,
|
||||
} from "@/lib/zitadel";
|
||||
import { getActiveIdentityProviders, getBrandingSettings, getDefaultOrg, getLoginSettings } from "@/lib/zitadel";
|
||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -16,12 +11,10 @@ import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("loginname");
|
||||
return { title: t('title')};
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const loginName = searchParams?.loginName;
|
||||
@@ -67,14 +60,16 @@ export default async function Page(props: {
|
||||
|
||||
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="title" namespace="loginname" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="description" namespace="loginname" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<UsernameForm
|
||||
loginName={loginName}
|
||||
requestId={requestId}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
|
||||
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="success.title" namespace="logout" />
|
||||
</h1>
|
||||
@@ -27,6 +27,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
<Translated i18nKey="success.description" namespace="logout" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full"></div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,14 +66,16 @@ export default async function Page(props: { searchParams: Promise<Record<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="title" namespace="logout" />
|
||||
</h1>
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="description" namespace="logout" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex w-full flex-col space-y-2">
|
||||
<SessionsClearList
|
||||
sessions={sessions}
|
||||
|
||||
@@ -7,23 +7,17 @@ import { UserAvatar } from "@/components/user-avatar";
|
||||
import { getSessionCookieById } from "@/lib/cookies";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import { loadMostRecentSession } from "@/lib/session";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getSession,
|
||||
listAuthenticationMethodTypes,
|
||||
} from "@/lib/zitadel";
|
||||
import { getBrandingSettings, getSession, listAuthenticationMethodTypes } from "@/lib/zitadel";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("mfa");
|
||||
return { title: t('verify.title')};
|
||||
return { title: t("verify.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const { loginName, requestId, organization, sessionId } = searchParams;
|
||||
@@ -35,11 +29,7 @@ export default async function Page(props: {
|
||||
? await loadSessionById(serviceUrl, sessionId, organization)
|
||||
: await loadSessionByLoginname(serviceUrl, loginName, organization);
|
||||
|
||||
async function loadSessionByLoginname(
|
||||
serviceUrl: string,
|
||||
loginName?: string,
|
||||
organization?: string,
|
||||
) {
|
||||
async function loadSessionByLoginname(serviceUrl: string, loginName?: string, organization?: string) {
|
||||
return loadMostRecentSession({
|
||||
serviceUrl,
|
||||
sessionParams: {
|
||||
@@ -61,11 +51,7 @@ export default async function Page(props: {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSessionById(
|
||||
host: string,
|
||||
sessionId: string,
|
||||
organization?: string,
|
||||
) {
|
||||
async function loadSessionById(host: string, sessionId: string, organization?: string) {
|
||||
const recent = await getSessionCookieById({ sessionId, organization });
|
||||
return getSession({
|
||||
serviceUrl,
|
||||
@@ -93,11 +79,10 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="verify.title" namespace="mfa" />
|
||||
</h1>
|
||||
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="verify.description" namespace="mfa" />
|
||||
</p>
|
||||
@@ -110,7 +95,9 @@ export default async function Page(props: {
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{!(loginName || sessionId) && (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
|
||||
@@ -114,7 +114,7 @@ export default async function Page(props: { searchParams: Promise<Record<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="set.title" namespace="mfa" />
|
||||
</h1>
|
||||
@@ -131,38 +131,42 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!(loginName || sessionId) && (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{!(loginName || sessionId) && (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!valid && (
|
||||
<Alert>
|
||||
<Translated i18nKey="sessionExpired" namespace="error" />
|
||||
</Alert>
|
||||
)}
|
||||
{!valid && (
|
||||
<Alert>
|
||||
<Translated i18nKey="sessionExpired" namespace="error" />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{valid && loginSettings && sessionWithData && sessionWithData.factors?.user?.id && (
|
||||
<ChooseSecondFactorToSetup
|
||||
userId={sessionWithData.factors?.user?.id}
|
||||
loginName={loginName}
|
||||
sessionId={sessionWithData.id}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
loginSettings={loginSettings}
|
||||
userMethods={sessionWithData.authMethods ?? []}
|
||||
phoneVerified={sessionWithData.phoneVerified ?? false}
|
||||
emailVerified={sessionWithData.emailVerified ?? false}
|
||||
checkAfter={checkAfter === "true"}
|
||||
force={force === "true"}
|
||||
></ChooseSecondFactorToSetup>
|
||||
)}
|
||||
{valid && loginSettings && sessionWithData && sessionWithData.factors?.user?.id && (
|
||||
<ChooseSecondFactorToSetup
|
||||
userId={sessionWithData.factors?.user?.id}
|
||||
loginName={loginName}
|
||||
sessionId={sessionWithData.id}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
loginSettings={loginSettings}
|
||||
userMethods={sessionWithData.authMethods ?? []}
|
||||
phoneVerified={sessionWithData.phoneVerified ?? false}
|
||||
emailVerified={sessionWithData.emailVerified ?? false}
|
||||
checkAfter={checkAfter === "true"}
|
||||
force={force === "true"}
|
||||
></ChooseSecondFactorToSetup>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton />
|
||||
<span className="flex-grow"></span>
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton />
|
||||
<span className="flex-grow"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default async function Page(props: {
|
||||
|
||||
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="verify.title" namespace="otp" />
|
||||
</h1>
|
||||
@@ -107,7 +107,9 @@ export default async function Page(props: {
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{method && session && (
|
||||
<LoginOTP
|
||||
loginName={loginName ?? session.factors?.user?.loginName}
|
||||
|
||||
@@ -7,13 +7,7 @@ import { Translated } from "@/components/translated";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import { loadMostRecentSession } from "@/lib/session";
|
||||
import {
|
||||
addOTPEmail,
|
||||
addOTPSMS,
|
||||
getBrandingSettings,
|
||||
getLoginSettings,
|
||||
registerTOTP,
|
||||
} from "@/lib/zitadel";
|
||||
import { addOTPEmail, addOTPSMS, getBrandingSettings, getLoginSettings, registerTOTP } from "@/lib/zitadel";
|
||||
import { RegisterTOTPResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
@@ -26,8 +20,7 @@ export default async function Page(props: {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const { loginName, organization, sessionId, requestId, checkAfter } =
|
||||
searchParams;
|
||||
const { loginName, organization, sessionId, requestId, checkAfter } = searchParams;
|
||||
const { method } = params;
|
||||
|
||||
const _headers = await headers();
|
||||
@@ -125,10 +118,25 @@ export default async function Page(props: {
|
||||
|
||||
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="set.title" namespace="otp" />
|
||||
</h1>
|
||||
|
||||
{totpResponse && "uri" in totpResponse && "secret" in totpResponse ? (
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="set.totpRegisterDescription" namespace="otp" />
|
||||
</p>
|
||||
) : (
|
||||
<p className="ztdl-p">
|
||||
{method === "email"
|
||||
? "Code via email was successfully added."
|
||||
: method === "sms"
|
||||
? "Code via SMS was successfully added."
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!session && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
@@ -151,53 +159,33 @@ export default async function Page(props: {
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{totpResponse && "uri" in totpResponse && "secret" in totpResponse ? (
|
||||
<>
|
||||
<p className="ztdl-p">
|
||||
<Translated
|
||||
i18nKey="set.totpRegisterDescription"
|
||||
namespace="otp"
|
||||
/>
|
||||
</p>
|
||||
<div>
|
||||
<TotpRegister
|
||||
uri={totpResponse.uri as string}
|
||||
secret={totpResponse.secret as string}
|
||||
loginName={loginName}
|
||||
sessionId={sessionId}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
checkAfter={checkAfter === "true"}
|
||||
loginSettings={loginSettings}
|
||||
></TotpRegister>
|
||||
</div>{" "}
|
||||
</>
|
||||
<div>
|
||||
<TotpRegister
|
||||
uri={totpResponse.uri as string}
|
||||
secret={totpResponse.secret as string}
|
||||
loginName={loginName}
|
||||
sessionId={sessionId}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
checkAfter={checkAfter === "true"}
|
||||
loginSettings={loginSettings}
|
||||
></TotpRegister>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="ztdl-p">
|
||||
{method === "email"
|
||||
? "Code via email was successfully added."
|
||||
: method === "sms"
|
||||
? "Code via SMS was successfully added."
|
||||
: ""}
|
||||
</p>
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton />
|
||||
<span className="flex-grow"></span>
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center">
|
||||
<BackButton />
|
||||
<span className="flex-grow"></span>
|
||||
|
||||
<Link href={urlToContinue}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
>
|
||||
<Translated i18nKey="set.submit" namespace="otp" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
<Link href={urlToContinue}>
|
||||
<Button type="submit" className="self-end" variant={ButtonVariants.Primary}>
|
||||
<Translated i18nKey="set.submit" namespace="otp" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
|
||||
@@ -13,16 +13,13 @@ import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("passkey");
|
||||
return { title: t('verify.title')};
|
||||
return { title: t("verify.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const { loginName, altPassword, requestId, organization, sessionId } =
|
||||
searchParams;
|
||||
const { loginName, altPassword, requestId, organization, sessionId } = searchParams;
|
||||
|
||||
const _headers = await headers();
|
||||
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
||||
@@ -34,11 +31,7 @@ export default async function Page(props: {
|
||||
sessionParams: { loginName, organization },
|
||||
});
|
||||
|
||||
async function loadSessionById(
|
||||
serviceUrl: string,
|
||||
sessionId: string,
|
||||
organization?: string,
|
||||
) {
|
||||
async function loadSessionById(serviceUrl: string, sessionId: string, organization?: string) {
|
||||
const recent = await getSessionCookieById({ sessionId, organization });
|
||||
return getSession({
|
||||
serviceUrl,
|
||||
@@ -58,11 +51,15 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="verify.title" namespace="passkey" />
|
||||
</h1>
|
||||
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="verify.description" namespace="passkey" />
|
||||
</p>
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
@@ -71,16 +68,17 @@ export default async function Page(props: {
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="verify.description" namespace="passkey" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{!(loginName || sessionId) && (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{(loginName || sessionId) && (
|
||||
<LoginPasskey
|
||||
loginName={loginName}
|
||||
|
||||
@@ -63,6 +63,10 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
<Translated i18nKey="set.title" namespace="passkey" />
|
||||
</h1>
|
||||
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="set.description" namespace="passkey" />
|
||||
</p>
|
||||
|
||||
{session ? (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? session.factors?.user?.loginName}
|
||||
@@ -78,10 +82,9 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
) : null}
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="set.description" namespace="passkey" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Alert type={AlertType.INFO}>
|
||||
<span>
|
||||
<Translated i18nKey="set.info.description" namespace="passkey" />
|
||||
|
||||
@@ -5,23 +5,17 @@ import { Translated } from "@/components/translated";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import { loadMostRecentSession } from "@/lib/session";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getLoginSettings,
|
||||
getPasswordComplexitySettings,
|
||||
} from "@/lib/zitadel";
|
||||
import { getBrandingSettings, getLoginSettings, getPasswordComplexitySettings } from "@/lib/zitadel";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("password");
|
||||
return { title: t('change.title')};
|
||||
return { title: t("change.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const _headers = await headers();
|
||||
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
||||
|
||||
@@ -55,25 +49,20 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
{sessionFactors?.factors?.user?.displayName ?? (
|
||||
<Translated i18nKey="change.title" namespace="password" />
|
||||
)}
|
||||
</h1>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>{sessionFactors?.factors?.user?.displayName ?? <Translated i18nKey="change.title" namespace="password" />}</h1>
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="change.description" namespace="password" />
|
||||
</p>
|
||||
|
||||
{/* show error only if usernames should be shown to be unknown */}
|
||||
{(!sessionFactors || !loginName) &&
|
||||
!loginSettings?.ignoreUnknownUsernames && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{(!sessionFactors || !loginName) && !loginSettings?.ignoreUnknownUsernames && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
@@ -83,10 +72,10 @@ export default async function Page(props: {
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{passwordComplexity &&
|
||||
loginName &&
|
||||
sessionFactors?.factors?.user?.id ? (
|
||||
<div className="w-full">
|
||||
{passwordComplexity && loginName && sessionFactors?.factors?.user?.id ? (
|
||||
<ChangePasswordForm
|
||||
sessionId={sessionFactors.id}
|
||||
loginName={loginName}
|
||||
|
||||
@@ -5,11 +5,7 @@ import { Translated } from "@/components/translated";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import { loadMostRecentSession } from "@/lib/session";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getDefaultOrg,
|
||||
getLoginSettings,
|
||||
} from "@/lib/zitadel";
|
||||
import { getBrandingSettings, getDefaultOrg, getLoginSettings } from "@/lib/zitadel";
|
||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -17,12 +13,10 @@ import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("password");
|
||||
return { title: t('verify.title')};
|
||||
return { title: t("verify.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
let { loginName, organization, requestId } = searchParams;
|
||||
|
||||
@@ -66,26 +60,12 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
{sessionFactors?.factors?.user?.displayName ?? (
|
||||
<Translated i18nKey="verify.title" namespace="password" />
|
||||
)}
|
||||
</h1>
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>{sessionFactors?.factors?.user?.displayName ?? <Translated i18nKey="verify.title" namespace="password" />}</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="verify.description" namespace="password" />
|
||||
</p>
|
||||
|
||||
{/* show error only if usernames should be shown to be unknown */}
|
||||
{(!sessionFactors || !loginName) &&
|
||||
!loginSettings?.ignoreUnknownUsernames && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
@@ -94,6 +74,17 @@ export default async function Page(props: {
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{/* show error only if usernames should be shown to be unknown */}
|
||||
{(!sessionFactors || !loginName) && !loginSettings?.ignoreUnknownUsernames && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginName && (
|
||||
<PasswordForm
|
||||
|
||||
@@ -5,12 +5,7 @@ import { Translated } from "@/components/translated";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import { loadMostRecentSession } from "@/lib/session";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getLoginSettings,
|
||||
getPasswordComplexitySettings,
|
||||
getUserByID,
|
||||
} from "@/lib/zitadel";
|
||||
import { getBrandingSettings, getLoginSettings, getPasswordComplexitySettings, getUserByID } from "@/lib/zitadel";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
||||
import { Metadata } from "next";
|
||||
@@ -19,16 +14,13 @@ import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("password");
|
||||
return { title: t('set.title')};
|
||||
return { title: t("set.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const { userId, loginName, organization, requestId, code, initial } =
|
||||
searchParams;
|
||||
const { userId, loginName, organization, requestId, code, initial } = searchParams;
|
||||
|
||||
const _headers = await headers();
|
||||
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
||||
@@ -76,12 +68,8 @@ export default async function Page(props: {
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
{session?.factors?.user?.displayName ?? (
|
||||
<Translated i18nKey="set.title" namespace="password" />
|
||||
)}
|
||||
</h1>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>{session?.factors?.user?.displayName ?? <Translated i18nKey="set.title" namespace="password" />}</h1>
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="set.description" namespace="password" />
|
||||
</p>
|
||||
@@ -110,16 +98,16 @@ export default async function Page(props: {
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{!initial && (
|
||||
<Alert type={AlertType.INFO}>
|
||||
<Translated i18nKey="set.codeSent" namespace="password" />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passwordComplexity &&
|
||||
(loginName ?? user?.preferredLoginName) &&
|
||||
(userId ?? session?.factors?.user?.id) ? (
|
||||
{passwordComplexity && (loginName ?? user?.preferredLoginName) && (userId ?? session?.factors?.user?.id) ? (
|
||||
<SetPasswordForm
|
||||
code={code}
|
||||
userId={userId ?? (session?.factors?.user?.id as string)}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
if (!loginSettings?.allowRegister) {
|
||||
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="disabled.title" namespace="register" />
|
||||
</h1>
|
||||
@@ -79,20 +79,23 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
<Translated i18nKey="disabled.description" namespace="register" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full"></div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
|
||||
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="title" namespace="register" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="description" namespace="register" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{!organization && (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
let { firstname, lastname, email, organization, requestId } = searchParams;
|
||||
@@ -62,17 +60,20 @@ export default async function Page(props: {
|
||||
<Translated i18nKey="missingdata.description" namespace="register" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full"></div>
|
||||
</DynamicTheme>
|
||||
) : loginSettings?.allowRegister && loginSettings.allowUsernamePassword ? (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="password.title" namespace="register" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="description" namespace="register" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{legal && passwordComplexitySettings && (
|
||||
<SetRegisterPasswordForm
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
@@ -87,7 +88,7 @@ export default async function Page(props: {
|
||||
</DynamicTheme>
|
||||
) : (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="disabled.title" namespace="register" />
|
||||
</h1>
|
||||
@@ -95,6 +96,7 @@ export default async function Page(props: {
|
||||
<Translated i18nKey="disabled.description" namespace="register" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full"></div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,11 @@ import { Button, ButtonVariants } from "@/components/button";
|
||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { Translated } from "@/components/translated";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import {
|
||||
getMostRecentCookieWithLoginname,
|
||||
getSessionCookieById,
|
||||
} from "@/lib/cookies";
|
||||
import { getMostRecentCookieWithLoginname, getSessionCookieById } from "@/lib/cookies";
|
||||
import { completeDeviceAuthorization } from "@/lib/server/device";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import { loadMostRecentSession } from "@/lib/session";
|
||||
import {
|
||||
getBrandingSettings,
|
||||
getLoginSettings,
|
||||
getSession,
|
||||
} from "@/lib/zitadel";
|
||||
import { getBrandingSettings, getLoginSettings, getSession } from "@/lib/zitadel";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
@@ -22,14 +15,10 @@ import Link from "next/link";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("signedin");
|
||||
return { title: t('title', { user: '' })};
|
||||
return { title: t("title", { user: "" }) };
|
||||
}
|
||||
|
||||
async function loadSessionById(
|
||||
serviceUrl: string,
|
||||
sessionId: string,
|
||||
organization?: string,
|
||||
) {
|
||||
async function loadSessionById(serviceUrl: string, sessionId: string, organization?: string) {
|
||||
const recent = await getSessionCookieById({ sessionId, organization });
|
||||
return getSession({
|
||||
serviceUrl,
|
||||
@@ -70,7 +59,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
}).catch((err) => {
|
||||
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="error.title" namespace="signedin" />
|
||||
</h1>
|
||||
@@ -79,6 +68,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
</p>
|
||||
<Alert>{err.message}</Alert>
|
||||
</div>
|
||||
<div className="w-full"></div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
});
|
||||
@@ -101,13 +91,9 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
|
||||
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="title"
|
||||
namespace="signedin"
|
||||
data={{ user: sessionFactors?.factors?.user?.displayName }}
|
||||
/>
|
||||
<Translated i18nKey="title" namespace="signedin" data={{ user: sessionFactors?.factors?.user?.displayName }} />
|
||||
</h1>
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="description" namespace="signedin" />
|
||||
@@ -119,11 +105,12 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
showDropdown={!(requestId && requestId.startsWith("device_"))}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{requestId && requestId.startsWith("device_") && (
|
||||
<Alert type={AlertType.INFO}>
|
||||
You can now close this window and return to the device where you
|
||||
started the authorization process to continue.
|
||||
You can now close this window and return to the device where you started the authorization process to continue.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -132,11 +119,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
<span className="flex-grow"></span>
|
||||
|
||||
<Link href={loginSettings?.defaultRedirectUri}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="self-end"
|
||||
variant={ButtonVariants.Primary}
|
||||
>
|
||||
<Button type="submit" className="self-end" variant={ButtonVariants.Primary}>
|
||||
<Translated i18nKey="continue" namespace="signedin" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -51,11 +51,15 @@ export default async function Page(props: { searchParams: Promise<Record<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="verify.title" namespace="u2f" />
|
||||
</h1>
|
||||
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="verify.description" namespace="u2f" />
|
||||
</p>
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
@@ -64,16 +68,15 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="verify.description" namespace="u2f" />
|
||||
</p>
|
||||
|
||||
{!(loginName || sessionId) && (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{(loginName || sessionId) && (
|
||||
<LoginPasskey
|
||||
loginName={loginName}
|
||||
|
||||
@@ -12,12 +12,10 @@ import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("u2f");
|
||||
return { title: t('set.title')};
|
||||
return { title: t("set.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
|
||||
}) {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const { loginName, organization, requestId, checkAfter } = searchParams;
|
||||
@@ -45,6 +43,10 @@ export default async function Page(props: {
|
||||
<Translated i18nKey="set.title" namespace="u2f" />
|
||||
</h1>
|
||||
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<Translated i18nKey="set.description" namespace="u2f" />
|
||||
</p>
|
||||
|
||||
{sessionFactors && (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
@@ -53,11 +55,9 @@ export default async function Page(props: {
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
)}
|
||||
<p className="ztdl-p mb-6 block">
|
||||
{" "}
|
||||
<Translated i18nKey="set.description" namespace="u2f" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{!sessionFactors && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
|
||||
@@ -120,14 +120,29 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
|
||||
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="verify.title" namespace="verify" />
|
||||
</h1>
|
||||
<p className="ztdl-p mb-6 block">
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="verify.description" namespace="verify" />
|
||||
</p>
|
||||
|
||||
{sessionFactors ? (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
displayName={sessionFactors.factors?.user?.displayName}
|
||||
showDropdown
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
) : (
|
||||
user && (
|
||||
<UserAvatar loginName={user.preferredLoginName} displayName={human?.profile?.displayName} showDropdown={false} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{!id && (
|
||||
<div className="py-4">
|
||||
<Alert>
|
||||
@@ -144,19 +159,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionFactors ? (
|
||||
<UserAvatar
|
||||
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
|
||||
displayName={sessionFactors.factors?.user?.displayName}
|
||||
showDropdown
|
||||
searchParams={searchParams}
|
||||
></UserAvatar>
|
||||
) : (
|
||||
user && (
|
||||
<UserAvatar loginName={user.preferredLoginName} displayName={human?.profile?.displayName} showDropdown={false} />
|
||||
)
|
||||
)}
|
||||
|
||||
<VerifyForm
|
||||
loginName={loginName}
|
||||
organization={organization}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
|
||||
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="successTitle" namespace="verify" />
|
||||
</h1>
|
||||
@@ -67,14 +67,11 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
></UserAvatar>
|
||||
) : (
|
||||
user && (
|
||||
<UserAvatar
|
||||
loginName={user.preferredLoginName}
|
||||
displayName={human?.profile?.displayName}
|
||||
showDropdown={false}
|
||||
/>
|
||||
<UserAvatar loginName={user.preferredLoginName} displayName={human?.profile?.displayName} showDropdown={false} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full"></div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
|
||||
0
apps/login/src/components/LoginLayout.tsx
Normal file
0
apps/login/src/components/LoginLayout.tsx
Normal file
168
apps/login/src/components/avatar.test.tsx
Normal file
168
apps/login/src/components/avatar.test.tsx
Normal 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", "john.doe@example.com");
|
||||
expect(initials).toBe("JD");
|
||||
});
|
||||
|
||||
it("should get single initial from single name", () => {
|
||||
const initials = getInitials("John", "john@example.com");
|
||||
expect(initials).toBe("J");
|
||||
});
|
||||
|
||||
it("should get initials from loginName when name is empty", () => {
|
||||
const initials = getInitials("", "john.doe@example.com");
|
||||
expect(initials.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle loginName with underscore separator", () => {
|
||||
const initials = getInitials("", "john_doe@example.com");
|
||||
expect(initials).toBe("jd");
|
||||
});
|
||||
|
||||
it("should handle loginName with dash separator", () => {
|
||||
const initials = getInitials("", "john-doe@example.com");
|
||||
expect(initials).toBe("jd");
|
||||
});
|
||||
|
||||
it("should handle loginName with dot separator", () => {
|
||||
const initials = getInitials("", "john.doe@example.com");
|
||||
expect(initials).toBe("jd");
|
||||
});
|
||||
|
||||
it("should get initials from username part of email", () => {
|
||||
const initials = getInitials("", "testuser@example.com");
|
||||
expect(initials.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render avatar with initials", () => {
|
||||
const { container } = render(<Avatar name="John Doe" loginName="john@example.com" />);
|
||||
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="test@example.com" />);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render with shadow prop", () => {
|
||||
const { container } = render(<Avatar name="Test User" loginName="test@example.com" shadow={true} />);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render without shadow prop", () => {
|
||||
const { container } = render(<Avatar name="Test User" loginName="test@example.com" shadow={false} />);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render with image URL", () => {
|
||||
const { container } = render(
|
||||
<Avatar name="Test User" loginName="test@example.com" 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="test@example.com" />);
|
||||
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="test1@example.com" />);
|
||||
const avatar1 = container1.firstChild as HTMLElement;
|
||||
|
||||
// Change theme
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy";
|
||||
const { container: container2 } = render(<Avatar name="Test User" loginName="test2@example.com" />);
|
||||
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="test@example.com" />);
|
||||
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="test@example.com" />);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Color Generation", () => {
|
||||
it("should generate consistent colors for same loginName", () => {
|
||||
const { container: container1 } = render(<Avatar name="Test" loginName="same@example.com" />);
|
||||
const { container: container2 } = render(<Avatar name="Test" loginName="same@example.com" />);
|
||||
|
||||
expect(container1.firstChild).toBeTruthy();
|
||||
expect(container2.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render for different loginNames", () => {
|
||||
const { container: container1 } = render(<Avatar name="User 1" loginName="user1@example.com" />);
|
||||
const { container: container2 } = render(<Avatar name="User 2" loginName="user2@example.com" />);
|
||||
|
||||
expect(container1.firstChild).toBeTruthy();
|
||||
expect(container2.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Props Validation", () => {
|
||||
it("should handle null name", () => {
|
||||
const { container } = render(<Avatar name={null} loginName="test@example.com" />);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle undefined name", () => {
|
||||
const { container } = render(<Avatar name={undefined} loginName="test@example.com" />);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should require loginName", () => {
|
||||
const { container } = render(<Avatar name="Test" loginName="required@example.com" />);
|
||||
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>
|
||||
);
|
||||
|
||||
27
apps/login/src/components/background-wrapper.tsx
Normal file
27
apps/login/src/components/background-wrapper.tsx
Normal file
@@ -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
apps/login/src/components/button.test.tsx
Normal file
215
apps/login/src/components/button.test.tsx
Normal 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:/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
apps/login/src/components/card.test.tsx
Normal file
175
apps/login/src/components/card.test.tsx
Normal 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
apps/login/src/components/card.tsx
Normal file
72
apps/login/src/components/card.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
188
apps/login/src/components/input.test.tsx
Normal file
188
apps/login/src/components/input.test.tsx
Normal 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("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
69
apps/login/src/components/theme-switch.tsx
Normal file
69
apps/login/src/components/theme-switch.tsx
Normal file
@@ -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>
|
||||
|
||||
@@ -243,7 +243,12 @@ describe("isSessionValid", () => {
|
||||
const result = await isSessionValid({ serviceUrl: mockServiceUrl, session });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Session has no valid MFA factor. Configured methods:", expect.any(Array), "Session factors:", expect.any(Object));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Session has no valid MFA factor. Configured methods:",
|
||||
expect.any(Array),
|
||||
"Session factors:",
|
||||
expect.any(Object),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
62
apps/login/src/lib/theme-hooks.ts
Normal file
62
apps/login/src/lib/theme-hooks.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getThemeConfig } from "./theme";
|
||||
|
||||
/**
|
||||
* Custom hook that returns the effective layout mode, taking into account
|
||||
* both the theme configuration and responsive breakpoints.
|
||||
*
|
||||
* On medium screens and below (md: max-width 767px), it will automatically
|
||||
* switch to top-to-bottom layout regardless of the theme setting.
|
||||
*
|
||||
* NOTE: This is a client-side hook and requires "use client" directive.
|
||||
*/
|
||||
export function useResponsiveLayout(): { isSideBySide: boolean; isResponsiveOverride: boolean } {
|
||||
const themeConfig = getThemeConfig();
|
||||
const [isMdOrSmaller, setIsMdOrSmaller] = useState(false);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Mark as hydrated on client side
|
||||
setIsHydrated(true);
|
||||
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(max-width: 767px)"); // md breakpoint is 768px in Tailwind
|
||||
|
||||
// Set initial value
|
||||
setIsMdOrSmaller(mediaQuery.matches);
|
||||
|
||||
// Listen for changes
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setIsMdOrSmaller(e.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
// Cleanup
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
const configuredSideBySide = themeConfig.layout === "side-by-side";
|
||||
|
||||
// During SSR or before hydration, assume desktop (side-by-side if configured)
|
||||
// This prevents hydration mismatches
|
||||
const isSideBySide = configuredSideBySide && (isHydrated ? !isMdOrSmaller : true);
|
||||
const isResponsiveOverride = configuredSideBySide && isHydrated && isMdOrSmaller;
|
||||
|
||||
return { isSideBySide, isResponsiveOverride };
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook that returns the theme configuration for client-side usage.
|
||||
*
|
||||
* NOTE: This is a client-side hook and requires "use client" directive.
|
||||
*/
|
||||
export function useThemeConfig() {
|
||||
return getThemeConfig();
|
||||
}
|
||||
453
apps/login/src/lib/theme.test.ts
Normal file
453
apps/login/src/lib/theme.test.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
ThemeRoundness,
|
||||
ThemeLayout,
|
||||
ThemeAppearance,
|
||||
ThemeSpacing,
|
||||
ComponentRoundnessConfig,
|
||||
DEFAULT_COMPONENT_ROUNDNESS,
|
||||
DEFAULT_THEME,
|
||||
getThemeConfig,
|
||||
ROUNDNESS_CLASSES,
|
||||
getComponentRoundness,
|
||||
SPACING_STYLES,
|
||||
APPEARANCE_STYLES,
|
||||
} from "./theme";
|
||||
|
||||
describe("Theme Configuration", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset environment variables before each test
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment variables
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("DEFAULT_COMPONENT_ROUNDNESS", () => {
|
||||
it("should have correct default values for all components", () => {
|
||||
expect(DEFAULT_COMPONENT_ROUNDNESS).toEqual({
|
||||
card: "mid",
|
||||
button: "mid",
|
||||
input: "mid",
|
||||
image: "mid",
|
||||
avatar: "full",
|
||||
avatarContainer: "full",
|
||||
themeSwitch: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("should have all required component types", () => {
|
||||
const requiredComponents: (keyof ComponentRoundnessConfig)[] = [
|
||||
"card",
|
||||
"button",
|
||||
"input",
|
||||
"image",
|
||||
"avatar",
|
||||
"avatarContainer",
|
||||
"themeSwitch",
|
||||
];
|
||||
|
||||
requiredComponents.forEach((component) => {
|
||||
expect(DEFAULT_COMPONENT_ROUNDNESS).toHaveProperty(component);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_THEME", () => {
|
||||
it("should have all required properties", () => {
|
||||
expect(DEFAULT_THEME).toEqual({
|
||||
roundness: "mid",
|
||||
componentRoundness: DEFAULT_COMPONENT_ROUNDNESS,
|
||||
layout: "top-to-bottom",
|
||||
appearance: "flat",
|
||||
spacing: "regular",
|
||||
});
|
||||
});
|
||||
|
||||
it("should have valid default values", () => {
|
||||
expect(DEFAULT_THEME.roundness).toBe("mid");
|
||||
expect(DEFAULT_THEME.layout).toBe("top-to-bottom");
|
||||
expect(DEFAULT_THEME.appearance).toBe("flat");
|
||||
expect(DEFAULT_THEME.spacing).toBe("regular");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getThemeConfig", () => {
|
||||
it("should return default theme when no environment variables are set", () => {
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.roundness).toBe(DEFAULT_THEME.roundness);
|
||||
expect(config.layout).toBe(DEFAULT_THEME.layout);
|
||||
expect(config.appearance).toBe(DEFAULT_THEME.appearance);
|
||||
expect(config.spacing).toBe(DEFAULT_THEME.spacing);
|
||||
expect(config.componentRoundness).toEqual(DEFAULT_COMPONENT_ROUNDNESS);
|
||||
});
|
||||
|
||||
it("should use global roundness from environment variable", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.roundness).toBe("full");
|
||||
});
|
||||
|
||||
it("should apply global roundness to all components when env var is set", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy";
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.componentRoundness?.card).toBe("edgy");
|
||||
expect(config.componentRoundness?.button).toBe("edgy");
|
||||
expect(config.componentRoundness?.input).toBe("edgy");
|
||||
expect(config.componentRoundness?.image).toBe("edgy");
|
||||
expect(config.componentRoundness?.avatar).toBe("edgy");
|
||||
expect(config.componentRoundness?.avatarContainer).toBe("edgy");
|
||||
expect(config.componentRoundness?.themeSwitch).toBe("edgy");
|
||||
});
|
||||
|
||||
it("should use component-specific defaults when global roundness is not set", () => {
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.componentRoundness).toEqual(DEFAULT_COMPONENT_ROUNDNESS);
|
||||
expect(config.componentRoundness?.avatar).toBe("full");
|
||||
expect(config.componentRoundness?.avatarContainer).toBe("full");
|
||||
expect(config.componentRoundness?.card).toBe("mid");
|
||||
});
|
||||
|
||||
it("should use layout from environment variable", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_LAYOUT = "side-by-side";
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.layout).toBe("side-by-side");
|
||||
});
|
||||
|
||||
it("should use appearance from environment variable", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_APPEARANCE = "material";
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.appearance).toBe("material");
|
||||
});
|
||||
|
||||
it("should use spacing from environment variable", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_SPACING = "compact";
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.spacing).toBe("compact");
|
||||
});
|
||||
|
||||
it("should use background image from environment variable", () => {
|
||||
const backgroundUrl = "https://example.com/image.jpg";
|
||||
process.env.NEXT_PUBLIC_THEME_BACKGROUND_IMAGE = backgroundUrl;
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.backgroundImage).toBe(backgroundUrl);
|
||||
});
|
||||
|
||||
it("should have undefined background image when not set", () => {
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.backgroundImage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should combine multiple environment variables correctly", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
||||
process.env.NEXT_PUBLIC_THEME_LAYOUT = "side-by-side";
|
||||
process.env.NEXT_PUBLIC_THEME_APPEARANCE = "glass";
|
||||
process.env.NEXT_PUBLIC_THEME_SPACING = "compact";
|
||||
process.env.NEXT_PUBLIC_THEME_BACKGROUND_IMAGE = "https://example.com/bg.png";
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.roundness).toBe("full");
|
||||
expect(config.layout).toBe("side-by-side");
|
||||
expect(config.appearance).toBe("glass");
|
||||
expect(config.spacing).toBe("compact");
|
||||
expect(config.backgroundImage).toBe("https://example.com/bg.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ROUNDNESS_CLASSES", () => {
|
||||
it("should have classes for all roundness levels", () => {
|
||||
expect(ROUNDNESS_CLASSES).toHaveProperty("edgy");
|
||||
expect(ROUNDNESS_CLASSES).toHaveProperty("mid");
|
||||
expect(ROUNDNESS_CLASSES).toHaveProperty("full");
|
||||
});
|
||||
|
||||
it("should have classes for all component types in each roundness level", () => {
|
||||
const components: (keyof ComponentRoundnessConfig)[] = [
|
||||
"card",
|
||||
"button",
|
||||
"input",
|
||||
"image",
|
||||
"avatar",
|
||||
"avatarContainer",
|
||||
"themeSwitch",
|
||||
];
|
||||
|
||||
const roundnessLevels: ThemeRoundness[] = ["edgy", "mid", "full"];
|
||||
|
||||
roundnessLevels.forEach((level) => {
|
||||
components.forEach((component) => {
|
||||
expect(ROUNDNESS_CLASSES[level]).toHaveProperty(component);
|
||||
expect(typeof ROUNDNESS_CLASSES[level][component]).toBe("string");
|
||||
expect(ROUNDNESS_CLASSES[level][component].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should have distinct classes for different roundness levels", () => {
|
||||
const components: (keyof ComponentRoundnessConfig)[] = ["card", "button", "input"];
|
||||
|
||||
components.forEach((component) => {
|
||||
// Each roundness level should have different classes for the same component
|
||||
expect(ROUNDNESS_CLASSES.edgy[component]).not.toBe(ROUNDNESS_CLASSES.mid[component]);
|
||||
expect(ROUNDNESS_CLASSES.mid[component]).not.toBe(ROUNDNESS_CLASSES.full[component]);
|
||||
expect(ROUNDNESS_CLASSES.edgy[component]).not.toBe(ROUNDNESS_CLASSES.full[component]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getComponentRoundness", () => {
|
||||
it("should return a valid CSS class string for any component", () => {
|
||||
const cardClass = getComponentRoundness("card");
|
||||
expect(typeof cardClass).toBe("string");
|
||||
expect(cardClass.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return different classes for different components with default config", () => {
|
||||
const cardClass = getComponentRoundness("card");
|
||||
const avatarClass = getComponentRoundness("avatar");
|
||||
|
||||
// Avatar defaults to full roundness, card to mid - they should be different
|
||||
expect(cardClass).not.toBe(avatarClass);
|
||||
});
|
||||
|
||||
it("should change output when global roundness environment variable changes", () => {
|
||||
// Get default
|
||||
const defaultCardClass = getComponentRoundness("card");
|
||||
|
||||
// Set to different roundness
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy";
|
||||
const edgyCardClass = getComponentRoundness("card");
|
||||
|
||||
// Should be different
|
||||
expect(edgyCardClass).not.toBe(defaultCardClass);
|
||||
|
||||
// Try another roundness level
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
||||
const fullCardClass = getComponentRoundness("card");
|
||||
|
||||
expect(fullCardClass).not.toBe(defaultCardClass);
|
||||
expect(fullCardClass).not.toBe(edgyCardClass);
|
||||
});
|
||||
|
||||
it("should return classes for all component types", () => {
|
||||
const components: (keyof ComponentRoundnessConfig)[] = [
|
||||
"card",
|
||||
"button",
|
||||
"input",
|
||||
"image",
|
||||
"avatar",
|
||||
"avatarContainer",
|
||||
"themeSwitch",
|
||||
];
|
||||
|
||||
components.forEach((component) => {
|
||||
const cssClass = getComponentRoundness(component);
|
||||
expect(typeof cssClass).toBe("string");
|
||||
expect(cssClass.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply global roundness to all components when set", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
||||
|
||||
const cardClass = getComponentRoundness("card");
|
||||
const buttonClass = getComponentRoundness("button");
|
||||
const avatarClass = getComponentRoundness("avatar");
|
||||
|
||||
// All should get classes from the "full" roundness level
|
||||
expect(cardClass).toBe(ROUNDNESS_CLASSES.full.card);
|
||||
expect(buttonClass).toBe(ROUNDNESS_CLASSES.full.button);
|
||||
expect(avatarClass).toBe(ROUNDNESS_CLASSES.full.avatar);
|
||||
});
|
||||
|
||||
it("should respect component-specific defaults when no global roundness is set", () => {
|
||||
delete process.env.NEXT_PUBLIC_THEME_ROUNDNESS;
|
||||
|
||||
const avatarClass = getComponentRoundness("avatar");
|
||||
const cardClass = getComponentRoundness("card");
|
||||
|
||||
// Avatar should use its default (full), card should use its default (mid)
|
||||
expect(avatarClass).toBe(ROUNDNESS_CLASSES.full.avatar);
|
||||
expect(cardClass).toBe(ROUNDNESS_CLASSES.mid.card);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SPACING_STYLES", () => {
|
||||
it("should have regular and compact spacing options", () => {
|
||||
expect(SPACING_STYLES).toHaveProperty("regular");
|
||||
expect(SPACING_STYLES).toHaveProperty("compact");
|
||||
});
|
||||
|
||||
it("should have spacing and padding properties for each option", () => {
|
||||
expect(SPACING_STYLES.regular).toHaveProperty("spacing");
|
||||
expect(SPACING_STYLES.regular).toHaveProperty("padding");
|
||||
expect(SPACING_STYLES.compact).toHaveProperty("spacing");
|
||||
expect(SPACING_STYLES.compact).toHaveProperty("padding");
|
||||
});
|
||||
|
||||
it("should have non-empty string values for all properties", () => {
|
||||
expect(typeof SPACING_STYLES.regular.spacing).toBe("string");
|
||||
expect(SPACING_STYLES.regular.spacing.length).toBeGreaterThan(0);
|
||||
expect(typeof SPACING_STYLES.regular.padding).toBe("string");
|
||||
expect(SPACING_STYLES.regular.padding.length).toBeGreaterThan(0);
|
||||
|
||||
expect(typeof SPACING_STYLES.compact.spacing).toBe("string");
|
||||
expect(SPACING_STYLES.compact.spacing.length).toBeGreaterThan(0);
|
||||
expect(typeof SPACING_STYLES.compact.padding).toBe("string");
|
||||
expect(SPACING_STYLES.compact.padding.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should have different values between regular and compact", () => {
|
||||
expect(SPACING_STYLES.regular.spacing).not.toBe(SPACING_STYLES.compact.spacing);
|
||||
expect(SPACING_STYLES.regular.padding).not.toBe(SPACING_STYLES.compact.padding);
|
||||
});
|
||||
});
|
||||
|
||||
describe("APPEARANCE_STYLES", () => {
|
||||
it("should have flat, material, and glass appearance options", () => {
|
||||
expect(APPEARANCE_STYLES).toHaveProperty("flat");
|
||||
expect(APPEARANCE_STYLES).toHaveProperty("material");
|
||||
expect(APPEARANCE_STYLES).toHaveProperty("glass");
|
||||
});
|
||||
|
||||
it("should have required properties for each appearance", () => {
|
||||
const requiredProperties = ["card", "button", "idp-button", "typography", "background"];
|
||||
|
||||
Object.values(APPEARANCE_STYLES).forEach((style) => {
|
||||
requiredProperties.forEach((prop) => {
|
||||
expect(style).toHaveProperty(prop);
|
||||
// @ts-ignore - dynamic property access
|
||||
expect(typeof style[prop]).toBe("string");
|
||||
// @ts-ignore - dynamic property access
|
||||
expect(style[prop].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should have different styles for different appearances", () => {
|
||||
// Each appearance should have distinct card styles
|
||||
expect(APPEARANCE_STYLES.flat.card).not.toBe(APPEARANCE_STYLES.material.card);
|
||||
expect(APPEARANCE_STYLES.material.card).not.toBe(APPEARANCE_STYLES.glass.card);
|
||||
expect(APPEARANCE_STYLES.flat.card).not.toBe(APPEARANCE_STYLES.glass.card);
|
||||
|
||||
// Each appearance should have distinct button styles
|
||||
expect(APPEARANCE_STYLES.flat.button).not.toBe(APPEARANCE_STYLES.material.button);
|
||||
expect(APPEARANCE_STYLES.material.button).not.toBe(APPEARANCE_STYLES.glass.button);
|
||||
expect(APPEARANCE_STYLES.flat.button).not.toBe(APPEARANCE_STYLES.glass.button);
|
||||
});
|
||||
|
||||
it("should have idp-button styles for all appearances", () => {
|
||||
expect(APPEARANCE_STYLES.flat["idp-button"]).toBeDefined();
|
||||
expect(typeof APPEARANCE_STYLES.flat["idp-button"]).toBe("string");
|
||||
|
||||
expect(APPEARANCE_STYLES.material["idp-button"]).toBeDefined();
|
||||
expect(typeof APPEARANCE_STYLES.material["idp-button"]).toBe("string");
|
||||
|
||||
expect(APPEARANCE_STYLES.glass["idp-button"]).toBeDefined();
|
||||
expect(typeof APPEARANCE_STYLES.glass["idp-button"]).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Type Safety", () => {
|
||||
it("should accept valid ThemeRoundness values", () => {
|
||||
const validValues: ThemeRoundness[] = ["edgy", "mid", "full"];
|
||||
validValues.forEach((value) => {
|
||||
expect(["edgy", "mid", "full"]).toContain(value);
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept valid ThemeLayout values", () => {
|
||||
const validValues: ThemeLayout[] = ["side-by-side", "top-to-bottom"];
|
||||
validValues.forEach((value) => {
|
||||
expect(["side-by-side", "top-to-bottom"]).toContain(value);
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept valid ThemeAppearance values", () => {
|
||||
const validValues: ThemeAppearance[] = ["flat", "material", "glass"];
|
||||
validValues.forEach((value) => {
|
||||
expect(["flat", "material", "glass"]).toContain(value);
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept valid ThemeSpacing values", () => {
|
||||
const validValues: ThemeSpacing[] = ["regular", "compact"];
|
||||
validValues.forEach((value) => {
|
||||
expect(["regular", "compact"]).toContain(value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration Tests", () => {
|
||||
it("should work correctly when switching between different roundness levels", () => {
|
||||
// Get classes for different roundness levels
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy";
|
||||
const edgyClass = getComponentRoundness("card");
|
||||
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "mid";
|
||||
const midClass = getComponentRoundness("card");
|
||||
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
||||
const fullClass = getComponentRoundness("card");
|
||||
|
||||
// All should be different
|
||||
expect(edgyClass).not.toBe(midClass);
|
||||
expect(midClass).not.toBe(fullClass);
|
||||
expect(edgyClass).not.toBe(fullClass);
|
||||
|
||||
// All should be valid strings
|
||||
expect(typeof edgyClass).toBe("string");
|
||||
expect(typeof midClass).toBe("string");
|
||||
expect(typeof fullClass).toBe("string");
|
||||
});
|
||||
|
||||
it("should maintain consistency across all theme properties", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
||||
process.env.NEXT_PUBLIC_THEME_LAYOUT = "side-by-side";
|
||||
process.env.NEXT_PUBLIC_THEME_APPEARANCE = "material";
|
||||
process.env.NEXT_PUBLIC_THEME_SPACING = "compact";
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
// Verify all properties are set correctly
|
||||
expect(config.roundness).toBe("full");
|
||||
expect(config.layout).toBe("side-by-side");
|
||||
expect(config.appearance).toBe("material");
|
||||
expect(config.spacing).toBe("compact");
|
||||
|
||||
// Verify component roundness is applied globally
|
||||
expect(config.componentRoundness?.card).toBe("full");
|
||||
expect(config.componentRoundness?.button).toBe("full");
|
||||
});
|
||||
|
||||
it("should handle empty string environment variables by using defaults", () => {
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "";
|
||||
process.env.NEXT_PUBLIC_THEME_LAYOUT = "";
|
||||
|
||||
const config = getThemeConfig();
|
||||
|
||||
expect(config.roundness).toBe(DEFAULT_THEME.roundness);
|
||||
expect(config.layout).toBe(DEFAULT_THEME.layout);
|
||||
});
|
||||
});
|
||||
});
|
||||
154
apps/login/src/lib/theme.ts
Normal file
154
apps/login/src/lib/theme.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// Theme configuration system for customizable login experience
|
||||
|
||||
export type ThemeRoundness = "edgy" | "mid" | "full";
|
||||
export type ThemeLayout = "side-by-side" | "top-to-bottom";
|
||||
export type ThemeAppearance = "flat" | "material" | "glass";
|
||||
export type ThemeSpacing = "regular" | "compact";
|
||||
|
||||
export interface ComponentRoundnessConfig {
|
||||
card: ThemeRoundness;
|
||||
button: ThemeRoundness;
|
||||
input: ThemeRoundness;
|
||||
image: ThemeRoundness;
|
||||
avatar: ThemeRoundness;
|
||||
avatarContainer: ThemeRoundness;
|
||||
themeSwitch: ThemeRoundness;
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
roundness: ThemeRoundness; // Global fallback
|
||||
componentRoundness?: ComponentRoundnessConfig; // Component-specific overrides
|
||||
layout: ThemeLayout;
|
||||
backgroundImage?: string;
|
||||
appearance: ThemeAppearance;
|
||||
spacing: ThemeSpacing;
|
||||
}
|
||||
|
||||
// Default component-specific roundness configuration
|
||||
export const DEFAULT_COMPONENT_ROUNDNESS: ComponentRoundnessConfig = {
|
||||
card: "mid",
|
||||
button: "mid",
|
||||
input: "mid",
|
||||
image: "mid",
|
||||
avatar: "full", // Avatars default to full roundness
|
||||
avatarContainer: "full", // Avatar containers default to full roundness
|
||||
themeSwitch: "full", // Theme switch defaults to full roundness
|
||||
};
|
||||
|
||||
// Default theme configuration
|
||||
export const DEFAULT_THEME: ThemeConfig = {
|
||||
roundness: "mid",
|
||||
componentRoundness: DEFAULT_COMPONENT_ROUNDNESS,
|
||||
layout: "top-to-bottom",
|
||||
appearance: "flat",
|
||||
spacing: "regular",
|
||||
};
|
||||
|
||||
// Get theme configuration from environment variables
|
||||
export function getThemeConfig(): ThemeConfig {
|
||||
const globalRoundness = process.env.NEXT_PUBLIC_THEME_ROUNDNESS as ThemeRoundness;
|
||||
|
||||
// If global roundness is set via env var, use it for all components
|
||||
// Otherwise, use component-specific defaults
|
||||
const componentRoundness = globalRoundness
|
||||
? {
|
||||
card: globalRoundness,
|
||||
button: globalRoundness,
|
||||
input: globalRoundness,
|
||||
image: globalRoundness,
|
||||
avatar: globalRoundness,
|
||||
avatarContainer: globalRoundness,
|
||||
themeSwitch: globalRoundness,
|
||||
}
|
||||
: DEFAULT_COMPONENT_ROUNDNESS;
|
||||
|
||||
return {
|
||||
roundness: globalRoundness || DEFAULT_THEME.roundness,
|
||||
componentRoundness: componentRoundness,
|
||||
layout: (process.env.NEXT_PUBLIC_THEME_LAYOUT as ThemeLayout) || DEFAULT_THEME.layout,
|
||||
backgroundImage: process.env.NEXT_PUBLIC_THEME_BACKGROUND_IMAGE || undefined,
|
||||
appearance: (process.env.NEXT_PUBLIC_THEME_APPEARANCE as ThemeAppearance) || DEFAULT_THEME.appearance,
|
||||
spacing: (process.env.NEXT_PUBLIC_THEME_SPACING as ThemeSpacing) || DEFAULT_THEME.spacing,
|
||||
};
|
||||
}
|
||||
|
||||
// Roundness CSS classes
|
||||
export const ROUNDNESS_CLASSES = {
|
||||
edgy: {
|
||||
card: "rounded-none",
|
||||
button: "rounded-none",
|
||||
input: "rounded-none",
|
||||
image: "rounded-none",
|
||||
avatar: "rounded-none",
|
||||
avatarContainer: "rounded-none",
|
||||
themeSwitch: "rounded-none",
|
||||
},
|
||||
mid: {
|
||||
card: "rounded-lg",
|
||||
button: "rounded-md",
|
||||
input: "rounded-md",
|
||||
image: "rounded-lg",
|
||||
avatar: "rounded-lg",
|
||||
avatarContainer: "rounded-md",
|
||||
themeSwitch: "rounded-md",
|
||||
},
|
||||
full: {
|
||||
card: "rounded-3xl",
|
||||
button: "rounded-full",
|
||||
input: "rounded-full pl-4",
|
||||
image: "rounded-full",
|
||||
avatar: "rounded-full",
|
||||
avatarContainer: "rounded-full",
|
||||
themeSwitch: "rounded-full",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Helper function to get component-specific roundness
|
||||
export function getComponentRoundness(componentType: keyof ComponentRoundnessConfig): string {
|
||||
const themeConfig = getThemeConfig();
|
||||
|
||||
// Use component-specific roundness if available, otherwise fall back to global roundness
|
||||
const roundnessLevel = themeConfig.componentRoundness?.[componentType] || themeConfig.roundness;
|
||||
|
||||
return ROUNDNESS_CLASSES[roundnessLevel][componentType];
|
||||
}
|
||||
|
||||
// Spacing configuration
|
||||
export const SPACING_STYLES = {
|
||||
regular: {
|
||||
spacing: "space-y-6",
|
||||
padding: "p-6 py-8",
|
||||
},
|
||||
compact: {
|
||||
spacing: "space-y-4",
|
||||
padding: "p-4",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Appearance styling (complete design philosophies)
|
||||
export const APPEARANCE_STYLES = {
|
||||
flat: {
|
||||
card: "bg-background-light-400 dark:bg-background-dark-500 border border-opacity-20 border border-black/10 dark:border-white/10",
|
||||
button: "border border-button-light-border dark:border-button-dark-border", // No shadows for flat design
|
||||
"idp-button": "border border-button-light-border dark:border-button-dark-border", // No shadows for flat design
|
||||
typography: "font-normal",
|
||||
background: "bg-background-light-500 dark:bg-background-dark-500", // Same as usual background
|
||||
},
|
||||
material: {
|
||||
card: "bg-background-light-400 dark:bg-background-dark-500 shadow-sm border-0",
|
||||
button: "shadow hover:shadow-xl active:shadow-xl", // Material shadows for buttons
|
||||
"idp-button":
|
||||
"!bg-background-[#00000020] !dark:bg-background-[#ffffff50] transition shadow shadow-md hover:shadow-lg active:shadow-xl", // Material shadows for IDP buttons
|
||||
typography: "font-medium",
|
||||
background: "bg-background-light-400 dark:bg-background-dark-500", // Current system (shade 400)
|
||||
},
|
||||
glass: {
|
||||
card: "backdrop-blur-md bg-white/10 dark:bg-black/10 border border-white/20 dark:border-white/10 shadow-xl",
|
||||
button:
|
||||
"backdrop-blur-sm bg-white/20 dark:bg-black/20 border border-white/30 dark:border-white/20 shadow-lg hover:shadow-xl", // Glass effect for buttons
|
||||
"idp-button":
|
||||
"backdrop-blur-sm bg-white/20 dark:bg-black/20 border border-white/30 dark:border-white/20 shadow-lg hover:shadow-xl", // Glass effect for IDP buttons
|
||||
typography: "font-medium",
|
||||
background: "bg-transparent", // Transparent background to show blur effect
|
||||
},
|
||||
} as const;
|
||||
49
apps/login/src/lib/themeUtils.tsx
Normal file
49
apps/login/src/lib/themeUtils.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
|
||||
// Generic theme-aware properties interface (kept for backward compatibility)
|
||||
export interface ThemeableProps {
|
||||
roundness?: string;
|
||||
spacing?: string;
|
||||
padding?: string;
|
||||
typography?: string;
|
||||
}
|
||||
|
||||
// Utility for conditional roundness classes (avoids Tailwind conflicts)
|
||||
export function getRoundnessClasses(roundness: string, baseClasses: string = "") {
|
||||
return clsx(baseClasses, {
|
||||
"rounded-none": roundness === "rounded-none",
|
||||
"rounded-md": roundness === "rounded-md",
|
||||
"rounded-full": roundness === "rounded-full",
|
||||
"rounded-lg": roundness === "rounded-lg",
|
||||
"rounded-3xl": roundness === "rounded-3xl",
|
||||
});
|
||||
}
|
||||
|
||||
// Utility for button-specific roundness
|
||||
export function getButtonRoundnessClasses(roundness: string) {
|
||||
return clsx({
|
||||
"rounded-none": roundness === "rounded-none",
|
||||
"rounded-md": roundness === "rounded-md",
|
||||
"rounded-full": roundness === "rounded-full",
|
||||
});
|
||||
}
|
||||
|
||||
// Utility for input-specific roundness
|
||||
export function getInputRoundnessClasses(roundness: string) {
|
||||
return clsx({
|
||||
"rounded-none": roundness === "rounded-none",
|
||||
"rounded-md": roundness === "rounded-md",
|
||||
"rounded-full": roundness === "rounded-full",
|
||||
});
|
||||
}
|
||||
|
||||
// Utility for card-specific roundness
|
||||
export function getCardRoundnessClasses(roundness: string) {
|
||||
return clsx({
|
||||
"rounded-none": roundness === "rounded-none",
|
||||
"rounded-lg": roundness === "rounded-lg",
|
||||
"rounded-3xl": roundness === "rounded-3xl",
|
||||
});
|
||||
}
|
||||
@@ -448,7 +448,7 @@ export async function createInviteCode({
|
||||
userId: string;
|
||||
}) {
|
||||
let medium = create(SendInviteCodeSchema, {
|
||||
applicationName: "Typescript Login",
|
||||
applicationName: process.env.NEXT_PUBLIC_APPLICATION_NAME || "Zitadel Login",
|
||||
});
|
||||
|
||||
medium = {
|
||||
@@ -1210,9 +1210,9 @@ export function createServerTransport(token: string, baseUrl: string) {
|
||||
(next) => {
|
||||
return (req) => {
|
||||
process.env.CUSTOM_REQUEST_HEADERS!.split(",").forEach((header) => {
|
||||
const kv = header.split(":");
|
||||
if (kv.length === 2) {
|
||||
req.header.set(kv[0].trim(), kv[1].trim());
|
||||
const kv = header.indexOf(":");
|
||||
if (kv > 0) {
|
||||
req.header.set(header.slice(0, kv).trim(), header.slice(kv + 1).trim());
|
||||
} else {
|
||||
console.warn(`Skipping malformed header: ${header}`);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
.ztdl-p {
|
||||
@apply text-center text-sm text-text-light-secondary-500 dark:text-text-dark-secondary-500;
|
||||
@apply text-sm text-text-light-secondary-500 dark:text-text-dark-secondary-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,7 @@ html {
|
||||
--accents-2: var(--theme-light-background-400);
|
||||
--accents-1: var(--theme-light-background-500);
|
||||
|
||||
background-image: linear-gradient(
|
||||
270deg,
|
||||
var(--accents-1),
|
||||
var(--accents-2),
|
||||
var(--accents-2),
|
||||
var(--accents-1)
|
||||
);
|
||||
background-image: linear-gradient(270deg, var(--accents-1), var(--accents-2), var(--accents-2), var(--accents-1));
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton_loading 8s ease-in-out infinite;
|
||||
}
|
||||
@@ -44,13 +38,7 @@ html {
|
||||
--accents-2: var(--theme-dark-background-400);
|
||||
--accents-1: var(--theme-dark-background-500);
|
||||
|
||||
background-image: linear-gradient(
|
||||
270deg,
|
||||
var(--accents-1),
|
||||
var(--accents-2),
|
||||
var(--accents-2),
|
||||
var(--accents-1)
|
||||
);
|
||||
background-image: linear-gradient(270deg, var(--accents-1), var(--accents-2), var(--accents-2), var(--accents-1));
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton_loading 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
56
apps/login/test-theme.js
Normal file
56
apps/login/test-theme.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Simple test script to verify theme roundness values
|
||||
console.log("Testing theme roundness values...\n");
|
||||
|
||||
// Test cases for different theme configurations
|
||||
const testCases = [
|
||||
{ roundness: "edgy", expected: { button: "rounded-none", card: "rounded-none", input: "rounded-none" } },
|
||||
{ roundness: "mid", expected: { button: "rounded-md", card: "rounded-lg", input: "rounded-md" } },
|
||||
{ roundness: "full", expected: { button: "rounded-full", card: "rounded-3xl", input: "rounded-full" } },
|
||||
];
|
||||
|
||||
// Mock process.env for testing
|
||||
function testThemeRoundness(roundnessValue) {
|
||||
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = roundnessValue;
|
||||
|
||||
// This simulates what the theme system should generate
|
||||
const ROUNDNESS_CLASSES = {
|
||||
edgy: {
|
||||
card: "rounded-none",
|
||||
button: "rounded-none",
|
||||
input: "rounded-none",
|
||||
image: "rounded-none",
|
||||
},
|
||||
mid: {
|
||||
card: "rounded-lg",
|
||||
button: "rounded-md",
|
||||
input: "rounded-md",
|
||||
image: "rounded-lg",
|
||||
},
|
||||
full: {
|
||||
card: "rounded-3xl",
|
||||
button: "rounded-full",
|
||||
input: "rounded-full",
|
||||
image: "rounded-full",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_THEME = { roundness: "mid" };
|
||||
const themeConfig = {
|
||||
roundness: process.env.NEXT_PUBLIC_THEME_ROUNDNESS || DEFAULT_THEME.roundness,
|
||||
};
|
||||
|
||||
return ROUNDNESS_CLASSES[themeConfig.roundness];
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testCases.forEach(({ roundness, expected }) => {
|
||||
const result = testThemeRoundness(roundness);
|
||||
console.log(`\n--- Testing ${roundness.toUpperCase()} theme ---`);
|
||||
console.log(`Button roundness: ${result.button} (expected: ${expected.button}) ${result.button === expected.button ? '✅' : '❌'}`);
|
||||
console.log(`Card roundness: ${result.card} (expected: ${expected.card}) ${result.card === expected.card ? '✅' : '❌'}`);
|
||||
console.log(`Input roundness: ${result.input} (expected: ${expected.input}) ${result.input === expected.input ? '✅' : '❌'}`);
|
||||
});
|
||||
|
||||
console.log("\n✅ All roundness values are correctly component-specific!");
|
||||
console.log("\nThis fixes the issue where buttons weren't getting the full rounded styles correctly.");
|
||||
console.log("Now 'full' theme generates 'rounded-full' for buttons and 'rounded-3xl' for cards.");
|
||||
Reference in New Issue
Block a user