Author SHA1 Message Date
Zeusina 992c11ebb6 fix: adjust margins and display properties for form elements 2026-07-01 20:06:21 +00:00
Zeusina 0a9baf4953 fix: update styles for footer and alerts in Template and tailwind 2026-07-01 19:55:26 +00:00
Zeusina 1d96de33d7 feat: center headline based on center, not baseline 2026-07-01 19:49:40 +00:00
Zeusina d17e1865ba fix: hide password button is now visible 2026-07-01 19:44:55 +00:00
Zeusina cd7211e367 fix: some fixes for glassmorphism 2026-07-01 18:51:49 +00:00
Zeusina 76f56c1571 feat: add basic glassmorphism 2026-07-01 18:30:14 +00:00
Zeusina 1a928923c4 feat: use shadcn default styles 2026-07-01 18:08:20 +00:00
Zeusina 4d98eaccbd feat: integrate shadcn/ui with Tailwind v4 2026-07-01 14:38:20 +00:00
Zeusina 11ce5b59b3 feat: update to zeusina keycloak
ci / test (push) Failing after 48s
ci / Check if version upgrade (push) Has been skipped
ci / create_github_release (push) Has been skipped
2026-07-01 14:12:40 +00:00
garronej 3772f97925 Update keycloakify 2026-06-15 15:54:59 +02:00
garronej 4053b5fb73 Update keycloakify 2026-06-07 21:02:14 +02:00
garronej 2a4c13268b Update keycloakify 2026-02-08 08:06:59 +01:00
garronej c5771b9e97 Update keycloakify 2026-01-24 08:32:31 +01:00
Joseph GarroneandGitHub 146956a58d Merge pull request #68 from lmller/patch-1
Replace broken link in readme.md
2026-01-19 10:32:42 +01:00
LovisandGitHub 55322bd691 Replace broken link in readme.md 2026-01-19 10:09:12 +01:00
garronej 20e691c379 Update keycloakify 2026-01-14 21:52:34 +01:00
garronej ac33f0346c Update keycloakify 2026-01-11 21:01:38 +01:00
garronej 00dd3dd5cb Update keycloakify 2026-01-08 06:02:52 +01:00
45 changed files with 6194 additions and 883 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ yarn install # Or use an other package manager, just be sure to delete the yarn.
# How to customize the theme # How to customize the theme
[Documentation](https://docs.keycloakify.dev/customization-strategies) [Documentation](https://docs.keycloakify.dev/css-customization)
# Building the theme # Building the theme
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/login/tailwind.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks",
"utils": "@/lib/utils"
}
}
+15 -12
View File
@@ -15,34 +15,37 @@ export default typescriptEslint.config(
eslintConfigPrettier, eslintConfigPrettier,
...storybook.configs["flat/recommended"], ...storybook.configs["flat/recommended"],
{ {
ignores: ["dist/**", "public/**"], ignores: ["dist/**", "public/**"]
}, },
{ {
plugins: { plugins: {
"react-refresh": reactRefresh, "react-refresh": reactRefresh,
"react-hooks": reactHooks, "react-hooks": reactHooks
}, },
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser
}, }
}, },
settings: { settings: {
react: { react: {
version: "detect", version: "detect"
}, }
}, },
rules: { rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true }
],
"react-hooks/exhaustive-deps": "off", "react-hooks/exhaustive-deps": "off",
"@typescript-eslint/no-redeclare": "off", "@typescript-eslint/no-redeclare": "off",
"no-labels": "off", "no-labels": "off"
}, }
}, },
{ {
files: ["**/*.stories.*"], files: ["**/*.stories.*"],
rules: { rules: {
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off"
}, }
}, }
); );
+16 -6
View File
@@ -1,10 +1,10 @@
{ {
"name": "keycloakify-starter", "name": "zeusina-keycloak",
"version": "0.0.0", "version": "0.1.0",
"description": "Starter for Keycloakify 11", "description": "Starter for Keycloakify 11",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/codegouvfr/keycloakify-starter.git" "url": "git://github.com/codegouvfr/zeusina-keycloak.git"
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -17,13 +17,22 @@
"license": "MIT", "license": "MIT",
"keywords": [], "keywords": [],
"dependencies": { "dependencies": {
"keycloakify": "^11.13.2", "@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@tailwindcss/vite": "^4.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"keycloakify": "^11.15.9",
"lucide-react": "^1.23.0",
"radix-ui": "^1.6.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.15.0", "@eslint/js": "^9.15.0",
"storybook": "^8.1.10",
"@storybook/react": "^8.1.10", "@storybook/react": "^8.1.10",
"@storybook/react-vite": "^8.1.10", "@storybook/react-vite": "^8.1.10",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
@@ -39,6 +48,7 @@
"eslint-plugin-storybook": "^0.11.1", "eslint-plugin-storybook": "^0.11.1",
"globals": "^15.12.0", "globals": "^15.12.0",
"prettier": "3.3.1", "prettier": "3.3.1",
"storybook": "^8.1.10",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"typescript-eslint": "^8.15.0", "typescript-eslint": "^8.15.0",
"vite": "^5.0.8" "vite": "^5.0.8"
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current"
}
},
defaultVariants: {
variant: "default"
}
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+86
View File
@@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
);
}
export { Input };
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };
+3 -3
View File
@@ -1,5 +1,5 @@
// This file is auto-generated by the `update-kc-gen` command. Do not edit it manually. // This file is auto-generated by the `update-kc-gen` command. Do not edit it manually.
// Hash: b0aa286dba4ed04c111c1265bdb6a76ba45c22a3d9b9e8dcf36302267d40c51a // Hash: e191f9b452c75f9d930415f1a0432ecc824f759d88ab12936351a85cde4b9c81
/* eslint-disable */ /* eslint-disable */
@@ -9,9 +9,9 @@
import { lazy, Suspense, type ReactNode } from "react"; import { lazy, Suspense, type ReactNode } from "react";
export type ThemeName = "keycloakify-starter"; export type ThemeName = "zeusina-keycloak";
export const themeNames: ThemeName[] = ["keycloakify-starter"]; export const themeNames: ThemeName[] = ["zeusina-keycloak"];
export type KcEnvName = never; export type KcEnvName = never;
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+24 -1
View File
@@ -9,6 +9,29 @@ export type KcContextExtension = {
// See: https://docs.keycloakify.dev/faq-and-help/some-values-you-need-are-missing-from-in-kccontext // See: https://docs.keycloakify.dev/faq-and-help/some-values-you-need-are-missing-from-in-kccontext
}; };
export type KcContextExtensionPerPage = {}; export type KcContextExtensionPerPage = {
"qr-login-scan.ftl": {
QRauthImage?: string;
QRauthExecId?: string;
QRauthToken?: string;
tabId?: string;
alignment?: "Center" | "Left" | "Right";
ShortCode?: string;
ShortCodeLink?: string;
refreshRate?: number;
};
"qr-login-verify.ftl": {
ua_device?: string;
ua_os?: string;
ua_agent?: string;
local_localized?: string;
tabId?: string;
approveURL?: string;
rejectURL?: string;
};
"qr-login-success.ftl": {};
"qr-login-canceled.ftl": {};
"qr-login-short-start.ftl": {};
};
export type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>; export type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>;
+215 -3
View File
@@ -3,11 +3,19 @@ import type { ClassKey } from "keycloakify/login";
import type { KcContext } from "./KcContext"; import type { KcContext } from "./KcContext";
import { useI18n } from "./i18n"; import { useI18n } from "./i18n";
import DefaultPage from "keycloakify/login/DefaultPage"; import DefaultPage from "keycloakify/login/DefaultPage";
import Template from "keycloakify/login/Template"; import Template from "./Template";
import "./tailwind.css";
const UserProfileFormFields = lazy( const UserProfileFormFields = lazy(
() => import("keycloakify/login/UserProfileFormFields") () => import("keycloakify/login/UserProfileFormFields")
); );
const QrLoginScan = lazy(() => import("./pages/QrLoginScan"));
const QrLoginVerify = lazy(() => import("./pages/QrLoginVerify"));
const QrLoginSuccess = lazy(() => import("./pages/QrLoginSuccess"));
const QrLoginCanceled = lazy(() => import("./pages/QrLoginCanceled"));
const QrLoginShortStart = lazy(() => import("./pages/QrLoginShortStart"));
const doMakeUserConfirmPassword = true; const doMakeUserConfirmPassword = true;
export default function KcPage(props: { kcContext: KcContext }) { export default function KcPage(props: { kcContext: KcContext }) {
@@ -19,6 +27,56 @@ export default function KcPage(props: { kcContext: KcContext }) {
<Suspense> <Suspense>
{(() => { {(() => {
switch (kcContext.pageId) { switch (kcContext.pageId) {
case "qr-login-scan.ftl":
return (
<QrLoginScan
kcContext={kcContext}
i18n={i18n}
classes={classes}
Template={Template}
doUseDefaultCss={false}
/>
);
case "qr-login-verify.ftl":
return (
<QrLoginVerify
kcContext={kcContext}
i18n={i18n}
classes={classes}
Template={Template}
doUseDefaultCss={false}
/>
);
case "qr-login-success.ftl":
return (
<QrLoginSuccess
kcContext={kcContext}
i18n={i18n}
classes={classes}
Template={Template}
doUseDefaultCss={false}
/>
);
case "qr-login-canceled.ftl":
return (
<QrLoginCanceled
kcContext={kcContext}
i18n={i18n}
classes={classes}
Template={Template}
doUseDefaultCss={false}
/>
);
case "qr-login-short-start.ftl":
return (
<QrLoginShortStart
kcContext={kcContext}
i18n={i18n}
classes={classes}
Template={Template}
doUseDefaultCss={false}
/>
);
default: default:
return ( return (
<DefaultPage <DefaultPage
@@ -26,7 +84,7 @@ export default function KcPage(props: { kcContext: KcContext }) {
i18n={i18n} i18n={i18n}
classes={classes} classes={classes}
Template={Template} Template={Template}
doUseDefaultCss={true} doUseDefaultCss={false}
UserProfileFormFields={UserProfileFormFields} UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword} doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/> />
@@ -37,4 +95,158 @@ export default function KcPage(props: { kcContext: KcContext }) {
); );
} }
const classes = {} satisfies { [key in ClassKey]?: string }; const classes = {
kcHtmlClass: "",
kcBodyClass: "",
kcLoginClass:
"relative z-[1] min-h-screen flex flex-col items-center justify-center px-4 pb-8 sm:px-[18px] mac-slide-up",
kcHeaderWrapperClass: "max-w-[600px] mx-auto",
kcHeaderClass: "mb-6 text-center glass-text-stable",
kcFormCardClass:
"glass relative w-full sm:w-[460px] rounded-xl px-4 py-5 sm:px-7 sm:py-[30px]",
kcFormHeaderClass: "relative grid grid-cols-[1fr_auto] gap-3 items-center mb-1",
kcLocaleMainClass: "m-0 col-start-2 row-start-1 flex items-center",
kcContentClass: "mt-[18px]",
kcContentWrapperClass: "",
kcFormClass: "grid gap-3",
kcFormAreaClass: "",
kcFormGroupClass: "",
kcFormGroupErrorClass: "",
kcFormGroupHeader: "",
kcLabelWrapperClass: "",
kcLabelClass: "block mb-1.5 text-[0.84rem] text-foreground font-medium",
kcInputClass: "",
kcInputLargeClass: "",
kcTextareaClass: "",
kcInputErrorMessageClass: "block mt-1.5 text-[0.82rem] text-destructive",
kcButtonClass: "",
kcButtonBlockClass: "",
kcButtonLargeClass: "",
kcButtonPrimaryClass: "",
kcButtonDefaultClass: "",
kcButtonSecondaryClass: "",
kcFormButtonsWrapperClass: "mt-5",
kcFormButtonsClass: "grid gap-3",
kcFormSettingClass:
"flex items-center justify-between gap-2.5 mt-3 pt-3 pr-0.5 pb-0.5",
kcFormOptionsClass: "",
kcFormOptionsWrapperClass: "flex items-center justify-end min-h-[44px]",
kcAlertClass:
"flex items-center gap-2.5 rounded-md mb-3.5 border border-border px-3 py-2.5",
kcAlertTitleClass: "",
kcLocaleClass: "",
kcLocaleWrapperClass: "",
kcLocaleDropDownClass: "relative z-30 flex items-center justify-end",
kcLocaleListClass: "block list-none m-0",
kcLocaleListItemClass: "list-none",
kcLocaleItemClass:
"block px-3.5 py-2 text-[0.88rem] font-medium text-foreground transition-colors duration-120",
kcSelectAuthListClass: "grid gap-3",
kcSelectAuthListItemClass:
"flex items-center gap-3 p-3 rounded-md border border-border bg-background transition-all duration-140 hover:border-input hover:bg-muted",
kcSelectAuthListItemIconClass:
"shrink-0 w-10 h-10 rounded-full grid place-items-center bg-muted text-foreground",
kcSelectAuthListItemIconPropertyClass: "",
kcSelectAuthListItemArrowClass: "shrink-0 ml-auto",
kcSelectAuthListItemArrowIconClass: "",
kcSelectAuthListItemBodyClass:
"flex-1 min-w-0 flex flex-col items-center gap-0.5 text-center",
kcSelectAuthListItemHeadingClass:
"text-[0.96rem] font-semibold text-foreground leading-tight text-center",
kcSelectAuthListItemDescriptionClass:
"text-[0.82rem] font-medium text-muted-foreground leading-snug text-center",
kcSelectAuthListItemFillClass: "hidden",
kcSelectAuthListItemTitle:
"m-0 mb-1 text-[0.9rem] font-semibold text-muted-foreground",
kcFormSocialAccountSectionClass: "",
kcFormSocialAccountListClass: "list-none m-0 p-0 grid gap-2",
kcFormSocialAccountListGridClass: "grid-cols-2 max-sm:grid-cols-1",
kcFormSocialAccountGridItem: "",
kcFormSocialAccountLinkClass: "",
kcFormSocialAccountListButtonClass:
"flex items-center gap-2.5 w-full min-h-10 rounded-md border border-border bg-background px-3 py-2 text-foreground no-underline transition-all duration-140 hover:bg-muted",
kcFormSocialAccountNameClass: "text-[0.95rem] font-semibold leading-tight",
kcCommonLogoIdP: "",
kcLoginOTPListInputClass: "absolute opacity-0 w-0 h-0",
kcLoginOTPListClass: "",
kcLoginOTPListItemHeaderClass: "flex items-center w-full",
kcLoginOTPListItemIconClass: "hidden",
kcLoginOTPListItemIconBodyClass: "hidden",
kcLoginOTPListItemTitleClass: "text-[0.98rem] font-semibold text-foreground",
kcInputWrapperClass: "grid gap-2",
kcInputGroup: "",
kcInputHelperTextBeforeClass: "",
kcInputHelperTextAfterClass: "",
kcFormPasswordVisibilityButtonClass: "",
kcFormPasswordVisibilityIconShow: "",
kcFormPasswordVisibilityIconHide: "",
kcCheckClass: "",
kcCheckInputClass: "",
kcCheckLabelClass: "",
kcCheckboxInputClass: "",
kcInputClassCheckbox: "",
kcInputClassCheckboxInput: "",
kcInputClassCheckboxLabel: "",
kcInputClassRadio: "",
kcInputClassRadioInput: "",
kcInputClassRadioLabel: "",
kcInputClassRadioCheckboxLabelDisabled: "",
kcFeedbackAreaClass: "",
kcFeedbackErrorIcon: "",
kcFeedbackInfoIcon: "",
kcFeedbackSuccessIcon: "",
kcFeedbackWarningIcon: "",
kcInfoAreaClass: "",
kcInfoAreaWrapperClass: "",
kcContainerClass: "",
kcLogoClass: "",
kcLogoLink: "",
kcSignUpClass: "",
kcSrOnlyClass: "",
kcAuthenticatorPasswordClass: "",
kcWebAuthnKeyIcon: "",
kcWebAuthnDefaultIcon: "",
kcWebAuthnUSB: "",
kcWebAuthnNFC: "",
kcWebAuthnBLE: "",
kcWebAuthnInternal: "",
kcWebAuthnUnknownIcon: "",
kcRecoveryCodesWarning: "",
kcRecoveryCodesActions: "",
kcRecoveryCodesList: "",
kcRecoveryCodesConfirmation: "",
kcResetFlowIcon: "",
"kcLogoIdP-google": "",
"kcLogoIdP-microsoft": "",
"kcLogoIdP-facebook": "",
"kcLogoIdP-twitter": "",
"kcLogoIdP-github": "",
"kcLogoIdP-linkedin": "",
"kcLogoIdP-instagram": "",
"kcLogoIdP-stackoverflow": "",
"kcLogoIdP-gitlab": "",
"kcLogoIdP-bitbucket": "",
"kcLogoIdP-paypal": "",
"kcLogoIdP-openshift-v3": "",
"kcLogoIdP-openshift-v4": ""
} satisfies { [key in ClassKey]?: string };
+7 -1
View File
@@ -11,7 +11,13 @@ const kcContextExtension: KcContextExtension = {
...kcEnvDefaults ...kcEnvDefaults
} }
}; };
const kcContextExtensionPerPage: KcContextExtensionPerPage = {}; const kcContextExtensionPerPage: KcContextExtensionPerPage = {
"qr-login-scan.ftl": {},
"qr-login-verify.ftl": {},
"qr-login-success.ftl": {},
"qr-login-canceled.ftl": {},
"qr-login-short-start.ftl": {}
};
export const { getKcContextMock } = createGetKcContextMock({ export const { getKcContextMock } = createGetKcContextMock({
kcContextExtension, kcContextExtension,
+194
View File
@@ -0,0 +1,194 @@
import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/login/Template.useInitialize";
import type { TemplateProps } from "keycloakify/login/TemplateProps";
import { CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { GlassAmbientBackground, GlassCard } from "./glass";
import { Alert, AlertDescription } from "@/components/ui/alert";
import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
kcContext,
i18n,
doUseDefaultCss,
classes,
children,
displayInfo = false,
displayMessage = true,
displayRequiredFields = false,
headerNode,
socialProvidersNode = null,
infoNode = null,
documentTitle,
bodyClassName
} = props;
const { kcClsx } = getKcClsx({ doUseDefaultCss, classes });
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { realm, auth, url, message, isAppInitiatedAction } = kcContext;
useEffect(() => {
document.title = documentTitle ?? msgStr("loginTitle", realm.displayName || realm.name);
}, [documentTitle, msgStr, realm.displayName, realm.name]);
useSetClassName({
qualifiedName: "html",
className: kcClsx("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: bodyClassName ?? kcClsx("kcBodyClass")
});
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
if (!isReadyToRender) {
return null;
}
const header = (() => {
const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
<h1 id="kc-page-title">{headerNode}</h1>
) : (
<div id="kc-username" className={kcClsx("kcFormGroupClass")}>
<label id="kc-attempted-username">{auth.attemptedUsername}</label>
<a id="reset-login" href={url.loginRestartFlowUrl} aria-label={msgStr("restartLoginTooltip")}>
<div className="kc-login-tooltip">
<i className={kcClsx("kcResetFlowIcon")} />
<span className="kc-tooltip-text">{msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
);
if (displayRequiredFields) {
return (
<div className={kcClsx("kcContentWrapperClass")}>
<div className={clsx(kcClsx("kcLabelWrapperClass"), "subtitle")}>
<span className="subtitle">
<span className="required">*</span>
{msg("requiredFields")}
</span>
</div>
<div className="col-md-10">{node}</div>
</div>
);
}
return node;
})();
return (
<GlassAmbientBackground>
<div className={kcClsx("kcLoginClass")}>
<div id="kc-header" className={kcClsx("kcHeaderClass")}>
<div id="kc-header-wrapper" className={kcClsx("kcHeaderWrapperClass")}>
{msg("loginTitleHtml", realm.displayNameHtml || realm.name)}
</div>
</div>
<GlassCard className={clsx(kcClsx("kcFormCardClass"), "w-full sm:w-[460px]")}>
<CardHeader className={clsx(kcClsx("kcFormHeaderClass"), "space-y-0 p-0 pb-4")}>
{enabledLanguages.length > 1 && (
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
<button
tabIndex={1}
id="kc-current-locale-link"
aria-label={msgStr("languages")}
aria-haspopup="true"
aria-expanded="false"
aria-controls="language-switch1"
>
{currentLanguage.label}
</button>
<ul
role="menu"
tabIndex={-1}
aria-labelledby="kc-current-locale-link"
aria-activedescendant=""
id="language-switch1"
className={kcClsx("kcLocaleListClass")}
>
{enabledLanguages.map(({ languageTag, label, href }, i: number) => (
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
<a role="menuitem" id={`language-${i + 1}`} className={kcClsx("kcLocaleItemClass")} href={href}>
{label}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{header}
</CardHeader>
<CardContent id="kc-content" className="p-0">
<div id="kc-content-wrapper">
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<Alert
className={clsx(
`alert-${message.type}`,
kcClsx("kcAlertClass"),
`pf-m-${message.type === "error" ? "danger" : message.type}`,
"mb-4"
)}
>
<AlertDescription
dangerouslySetInnerHTML={{
__html: kcSanitize(message.summary)
}}
/>
</Alert>
)}
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={kcClsx("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
id="try-another-way"
onClick={event => {
(document.forms.namedItem("kc-select-try-another-way-form") as HTMLFormElement).requestSubmit();
event.preventDefault();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
</form>
)}
{socialProvidersNode}
{displayInfo && (
<div id="kc-info" className={kcClsx("kcSignUpClass")}>
<div id="kc-info-wrapper" className={kcClsx("kcInfoAreaWrapperClass")}>
{infoNode}
</div>
</div>
)}
</div>
</CardContent>
{infoNode && !displayInfo && <CardFooter className="p-0 pt-4 border-t border-border">{infoNode}</CardFooter>}
</GlassCard>
</div>
</GlassAmbientBackground>
);
}
@@ -0,0 +1,97 @@
import * as React from "react";
/**
* Animated ambient background for the glassmorphism login theme.
* Uses only CSS transforms/opacity for GPU-composited motion.
* Respects `prefers-reduced-motion` (animation stops) and is aria-hidden.
*/
export function GlassAmbientBackground({ children }: { children: React.ReactNode }) {
return (
<>
<div
aria-hidden="true"
className="fixed inset-0 z-0 overflow-hidden pointer-events-none"
>
{/* Large, heavily blurred color blobs */}
<div
className="glass-ambient-blob"
style={{
width: "70vw",
height: "70vw",
left: "-22vw",
top: "-18vh",
background: "var(--glass-ambient-1)",
animationDuration: "30s",
animationDelay: "-6s"
}}
/>
<div
className="glass-ambient-blob"
style={{
width: "60vw",
height: "60vw",
right: "-16vw",
top: "2vh",
background: "var(--glass-ambient-2)",
animationDuration: "34s",
animationDelay: "-14s"
}}
/>
<div
className="glass-ambient-blob"
style={{
width: "55vw",
height: "55vw",
left: "10vw",
bottom: "-20vh",
background: "var(--glass-ambient-3)",
animationDuration: "38s",
animationDelay: "-10s"
}}
/>
<div
className="glass-ambient-blob"
style={{
width: "48vw",
height: "48vw",
right: "6vw",
bottom: "-8vh",
background: "var(--glass-ambient-4)",
animationDuration: "32s",
animationDelay: "-22s"
}}
/>
{/* Long flowing aurora bands */}
<div
className="glass-ambient-aurora"
style={{
width: "80vw",
height: "30vh",
left: "-10vw",
top: "35vh",
background: "var(--glass-ambient-5)",
animationDuration: "40s",
animationDelay: "-4s"
}}
/>
<div
className="glass-ambient-aurora"
style={{
width: "70vw",
height: "24vh",
right: "-12vw",
bottom: "25vh",
background: "var(--glass-ambient-6)",
animationDuration: "44s",
animationDelay: "-18s"
}}
/>
{/* Soft vignette to focus attention on the card */}
<div className="glass-vignette" />
</div>
{children}
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Glass card wrapper.
* Applies the base `.glass` utility and keeps the same flex/gap contract
* as the shadcn Card so upstream changes around the Card component are easy
* to merge: only this isolated component needs to be updated.
*/
export function GlassCard({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"glass relative flex flex-col gap-6 p-6 sm:p-8 text-card-foreground",
className
)}
{...props}
>
{children}
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
/**
* Glassmorphism / Liquid Glass theme tokens
*
* Isolated module: all glass-specific tokens and utilities live here.
* Changing upstream Keycloakify or shadcn files should not affect this file,
* and changes here are intentionally scoped to the login theme.
*/
:root {
/* Blur radius — keep between 1020px for GPU/UX balance */
--glass-blur: 14px;
--glass-blur-heavy: 20px;
/*
* Light theme glass tokens.
* Higher opacity than typical Dribbble glass so the card is readable
* and clearly separated from the animated background.
*/
--glass-bg: hsla(0 0% 100% / 0.42);
--glass-bg-solid: hsla(0 0% 100% / 0.82);
--glass-bg-scrim: hsla(0 0% 100% / 0.62);
--glass-border: hsla(0 0% 100% / 0.58);
--glass-border-highlight: hsla(0 0% 100% / 0.85);
--glass-shadow: 0 14px 44px hsla(220 40% 16% / 0.18);
--glass-shadow-elevated: 0 24px 64px hsla(220 40% 12% / 0.24);
/*
* Soft ambient background palette.
* Pastel, heavily blurred blobs for a calm liquid-glass feeling.
*/
--glass-ambient-1: hsla(250 90% 70% / 0.45);
--glass-ambient-2: hsla(320 85% 68% / 0.4);
--glass-ambient-3: hsla(190 90% 62% / 0.38);
--glass-ambient-4: hsla(40 95% 65% / 0.34);
--glass-ambient-5: hsla(140 80% 60% / 0.26);
--glass-ambient-6: hsla(10 90% 66% / 0.3);
/* Text stability on busy backgrounds */
--glass-text-shadow: 0 1px 2px hsla(0 0% 0% / 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
/* Dark glass needs to be noticeably darker/tinted or it disappears on a dark page */
--glass-bg: hsla(220 20% 14% / 0.62);
--glass-bg-solid: hsla(220 20% 10% / 0.9);
--glass-bg-scrim: hsla(220 20% 8% / 0.78);
--glass-border: hsla(0 0% 100% / 0.18);
--glass-border-highlight: hsla(0 0% 100% / 0.32);
--glass-shadow: 0 14px 44px hsla(0 0% 0% / 0.45);
--glass-shadow-elevated: 0 24px 64px hsla(0 0% 0% / 0.55);
/* Dark-mode blobs: vivid enough to refract, soft enough to not distract */
--glass-ambient-1: hsla(250 90% 58% / 0.38);
--glass-ambient-2: hsla(320 85% 56% / 0.34);
--glass-ambient-3: hsla(190 90% 54% / 0.32);
--glass-ambient-4: hsla(40 95% 58% / 0.28);
--glass-ambient-5: hsla(140 80% 50% / 0.22);
--glass-ambient-6: hsla(10 90% 58% / 0.26);
--glass-text-shadow: 0 1px 3px hsla(0 0% 0% / 0.4);
}
}
/* Reduced transparency: make glass almost opaque and drop the blur */
@media (prefers-reduced-transparency: reduce) {
:root {
--glass-bg: hsla(0 0% 100% / 0.95);
--glass-bg-solid: hsla(0 0% 100% / 0.99);
--glass-bg-scrim: hsla(0 0% 100% / 0.99);
--glass-border: hsla(0 0% 0% / 0.14);
--glass-border-highlight: hsla(0 0% 0% / 0.1);
--glass-blur: 2px;
--glass-blur-heavy: 2px;
}
@media (prefers-color-scheme: dark) {
:root {
--glass-bg: hsla(220 20% 8% / 0.97);
--glass-bg-solid: hsla(220 20% 6% / 0.99);
--glass-bg-scrim: hsla(220 20% 6% / 0.99);
--glass-border: hsla(0 0% 100% / 0.14);
--glass-border-highlight: hsla(0 0% 100% / 0.1);
}
}
}
/* Base glass layer — use for cards, panels, overlays */
.glass {
/* Tinted fill plus a subtle top-left sheen to mimic physical glass */
background: linear-gradient(
145deg,
hsla(0 0% 100% / 0.22) 0%,
hsla(0 0% 100% / 0.06) 55%,
hsla(0 0% 100% / 0.14) 100%
),
var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(145%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(145%);
border: 1px solid var(--glass-border);
box-shadow:
inset 0 1px 0 0 var(--glass-border-highlight),
inset 0 0 0 1px hsla(0 0% 100% / 0.04),
var(--glass-shadow);
border-radius: 1rem;
}
/* More opaque glass — used behind dense text or form sections */
.glass-solid {
background: linear-gradient(
145deg,
hsla(0 0% 100% / 0.18) 0%,
hsla(0 0% 100% / 0.04) 55%,
hsla(0 0% 100% / 0.1) 100%
),
var(--glass-bg-solid);
backdrop-filter: blur(var(--glass-blur-heavy)) saturate(125%);
-webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(125%);
border: 1px solid var(--glass-border);
box-shadow:
inset 0 1px 0 0 var(--glass-border-highlight),
inset 0 0 0 1px hsla(0 0% 100% / 0.04),
var(--glass-shadow);
border-radius: 0.875rem;
}
/* Scrim gradient for text protection on glass surfaces */
.glass-scrim {
background: linear-gradient(
180deg,
var(--glass-bg-scrim) 0%,
color-mix(in oklab, var(--glass-bg-scrim) 85%, transparent) 100%
);
}
/* Dark mode card sheen — cool tinted gradient instead of a white glare */
@media (prefers-color-scheme: dark) {
.glass {
background: linear-gradient(
145deg,
hsla(220 30% 26% / 0.35) 0%,
hsla(220 20% 12% / 0.12) 55%,
hsla(220 30% 22% / 0.28) 100%
),
var(--glass-bg);
box-shadow:
inset 0 1px 0 0 var(--glass-border-highlight),
inset 0 0 0 1px hsla(0 0% 100% / 0.05),
var(--glass-shadow);
}
.glass-solid {
background: linear-gradient(
145deg,
hsla(220 30% 22% / 0.3) 0%,
hsla(220 20% 10% / 0.08) 55%,
hsla(220 30% 18% / 0.22) 100%
),
var(--glass-bg-solid);
box-shadow:
inset 0 1px 0 0 var(--glass-border-highlight),
inset 0 0 0 1px hsla(0 0% 100% / 0.05),
var(--glass-shadow);
}
}
/* Fallback for browsers without backdrop-filter support */
@supports not (backdrop-filter: blur(12px)) {
.glass {
background: var(--glass-bg-solid);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.glass-solid {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
/*
* Ambient background blobs.
* Huge, very soft, slowly drifting color clouds.
* The heavy blur makes them feel like liquid light.
*/
.glass-ambient-blob {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.85;
will-change: transform, opacity;
animation: glass-float 28s ease-in-out infinite alternate;
}
@media (prefers-reduced-motion: reduce) {
.glass-ambient-blob {
animation: none;
opacity: 0.6;
}
}
@keyframes glass-float {
0% {
transform: translate3d(0, 0, 0) scale(1) rotate(0deg);
}
100% {
transform: translate3d(60px, -70px, 0) scale(1.22) rotate(12deg);
}
}
/*
* Aurora-like flowing gradient bands.
* Long, elliptical, heavily blurred shapes that drift across the screen.
*/
.glass-ambient-aurora {
position: absolute;
border-radius: 50%;
filter: blur(110px);
opacity: 0.7;
will-change: transform;
animation: glass-aurora-drift 36s ease-in-out infinite alternate;
}
@media (prefers-reduced-motion: reduce) {
.glass-ambient-aurora {
animation: none;
opacity: 0.5;
}
}
@keyframes glass-aurora-drift {
0% {
transform: translate3d(0, 0, 0) scale(1) rotate(-6deg);
}
100% {
transform: translate3d(-80px, 40px, 0) scale(1.18) rotate(8deg);
}
}
/*
* Subtle vignette overlay.
* Darkens the edges so the eye is drawn to the glass card in the center.
*/
.glass-vignette {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
ellipse at center,
transparent 0%,
transparent 50%,
hsla(0 0% 0% / 0.06) 100%
);
}
@media (prefers-color-scheme: dark) {
.glass-vignette {
background: radial-gradient(
ellipse at center,
transparent 0%,
transparent 45%,
hsla(0 0% 0% / 0.35) 100%
);
}
}
/* Text stability helper */
.glass-text-stable {
text-shadow: var(--glass-text-shadow);
}
+2
View File
@@ -0,0 +1,2 @@
export { GlassAmbientBackground } from "./GlassAmbientBackground";
export { GlassCard } from "./GlassCard";
+93 -1
View File
@@ -3,7 +3,99 @@ import { i18nBuilder } from "keycloakify/login";
import type { ThemeName } from "../kc.gen"; import type { ThemeName } from "../kc.gen";
/** @see: https://docs.keycloakify.dev/features/i18n */ /** @see: https://docs.keycloakify.dev/features/i18n */
const { useI18n, ofTypeI18n } = i18nBuilder.withThemeName<ThemeName>().build(); const { useI18n, ofTypeI18n } = i18nBuilder
.withThemeName<ThemeName>()
.withCustomTranslations({
en: {
doTryAnotherWay: "Try another way",
"identity-provider-login-label": "Or sign in with",
loginChooseAuthenticator: "Select authentication method",
"auth-username-password-form-display-name": "Username and password",
"auth-username-password-form-help-text":
"Sign in by entering your username and password.",
"password-display-name": "Password",
"password-help-text": "Sign in by entering your password.",
"otp-display-name": "Authenticator application",
"otp-help-text":
"Enter the verification code from your authenticator application.",
"webauthn-display-name": "Passkey",
"webauthn-help-text": "Use a passkey to sign in.",
"webauthn-passwordless-display-name": "Passkey",
"webauthn-passwordless-help-text":
"Use a passkey to sign in without a password.",
"ext-qr-code-login-display-name": "QR Code",
"ext-qr-code-login-help-text":
"Sign in with another device by scanning a QR code",
"auth-username-password-form-qr-code-display-name": "Username and password",
"auth-username-password-form-qr-code-help-text":
"Sign in by entering your username and password, or scan a QR code to use another device.",
qrLoginSessionLabel: "Session: ",
qrLoginLoading: "Loading QR code…",
"webauthn-login-title": "Passkey login",
"webauthn-available-authenticators": "Available Passkeys",
"webauthn-createdAt-label": "Created",
"webauthn-doAuthenticate": "Sign in with Passkey",
doQrCodeLogin: "Sign In with QR Code",
doQrCodeVerify: "Confirm this sign in",
doQrCodeWarning:
"Do not scan or approve QR codes sent by other people. If you are unsure, cancel the sign in.",
successQrCodeLoginTitle: "Success",
successQrCodeLoginMessage: "Click on the QR code to continue",
doShortCodeLogin: "Short Code",
UseShortCode: "Use Short Code",
CannotScan: "Cannot Scan",
doShortCodeInfo:
"Authorize another device, enter the device short code. Do not use codes sent by other people",
doShortCodeSteps:
"Open the login screen on another device. Select QR Code, and click Use Short Code. Enter the code shown below",
doShortCode: "Short code"
},
ru: {
doTryAnotherWay: "Попробовать другой способ",
"identity-provider-login-label": "Или войдите с помощью",
loginChooseAuthenticator: "Выберите способ входа",
"auth-username-password-form-display-name": "Имя пользователя и пароль",
"auth-username-password-form-help-text":
"Войдите, введя свое имя пользователя и пароль.",
"password-display-name": "Пароль",
"password-help-text": "Войдите, введя свой пароль.",
"otp-display-name": "Приложение-аутентификатор",
"otp-help-text": "Введите код подтверждения из приложения-аутентификатора.",
"webauthn-display-name": "Ключ доступа",
"webauthn-help-text": "Используйте ключ доступа для входа.",
"webauthn-passwordless-display-name": "Ключ доступа",
"webauthn-passwordless-help-text":
"Используйте ключ доступа для входа без пароля.",
"ext-qr-code-login-display-name": "QR Код",
"ext-qr-code-login-help-text":
"Войдите, отсканировав QR-код с другого устройства",
"auth-username-password-form-qr-code-display-name":
"Имя пользователя и пароль",
"auth-username-password-form-qr-code-help-text":
"Войдите, введя свое имя пользователя и пароль, или отсканируйте QR-код для использования другого устройства.",
qrLoginSessionLabel: "Сессия: ",
qrLoginLoading: "Загрузка QR-кода…",
"webauthn-login-title": "Вход с помощью ключа доступа",
"webauthn-available-authenticators": "Доступные ключи доступа",
"webauthn-createdAt-label": "Создан",
"webauthn-doAuthenticate": "Войти с помощью ключа доступа",
doQrCodeLogin: "Вход по QR-коду",
doQrCodeVerify: "Подтвердите этот вход",
doQrCodeWarning:
"Не сканируйте и не подтверждайте QR-коды, отправленные другими людьми. Если вы не уверены, отмените вход.",
successQrCodeLoginTitle: "Успешно",
successQrCodeLoginMessage: "Нажмите на QR-код, чтобы продолжить",
doShortCodeLogin: "Короткий код",
UseShortCode: "Использовать короткий код",
CannotScan: "Не могу отсканировать",
doShortCodeInfo:
"Авторизуйте другое устройство, введите короткий код устройства. Не используйте коды, отправленные другими людьми",
doShortCodeSteps:
"Откройте экран входа на другом устройстве. Выберите QR-код и нажмите Использовать короткий код. Введите код, показанный ниже",
doShortCode: "Короткий код"
}
})
.build();
type I18n = typeof ofTypeI18n; type I18n = typeof ofTypeI18n;
+56
View File
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "code.ftl" });
const meta = {
title: "login/code.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithErrorCode: Story = {
render: () => (
<KcPageStory
kcContext={{
code: {
success: false,
error: "Failed to generate code"
}
}}
/>
)
};
export const WithFrenchLanguage: Story = {
render: () => (
<KcPageStory
kcContext={{
locale: {
currentLanguageTag: "fr"
},
code: {
success: true,
code: "XYZ789"
}
}}
/>
)
};
export const WithHtmlErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
code: {
success: false,
error: "Something went wrong. <a href='https://example.com'>Try again</a>"
}
}}
/>
)
};
+62
View File
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "error.ftl" });
const meta = {
title: "login/error.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithAnotherMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "With another error message" }
}}
/>
)
};
export const WithHtmlErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "<strong>Error:</strong> Something went wrong. <a href='https://example.com'>Go back</a>"
}
}}
/>
)
};
export const FrenchError: Story = {
render: () => (
<KcPageStory
kcContext={{
locale: { currentLanguageTag: "fr" },
message: { summary: "Une erreur s'est produite" }
}}
/>
)
};
export const WithSkipLink: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "An error occurred" },
skipLink: true,
client: {
baseUrl: "https://example.com"
}
}}
/>
)
};
+59
View File
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "info.ftl" });
const meta = {
title: "login/info.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Server info message"
}
}}
/>
)
};
export const WithLinkBack: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Server message"
},
actionUri: undefined
}}
/>
)
};
export const WithRequiredActions: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Required actions:"
},
requiredActions: ["CONFIGURE_TOTP", "UPDATE_PROFILE", "VERIFY_EMAIL", "CUSTOM_ACTION"],
"x-keycloakify": {
messages: {
"requiredAction.CUSTOM_ACTION": "Custom action"
}
}
}}
/>
)
};
+407
View File
@@ -0,0 +1,407 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login.ftl" });
const meta = {
title: "login/login.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithInvalidCredential: Story = {
render: () => (
<KcPageStory
kcContext={{
login: {
username: "johndoe"
},
messagesPerField: {
// NOTE: The other functions of messagesPerField are derived from get() and
// existsError() so they are the only ones that need to mock.
existsError: (fieldName: string, ...otherFieldNames: string[]) => {
const fieldNames = [fieldName, ...otherFieldNames];
return fieldNames.includes("username") || fieldNames.includes("password");
},
get: (fieldName: string) => {
if (fieldName === "username" || fieldName === "password") {
return "Invalid username or password.";
}
return "";
}
}
}}
/>
)
};
export const WithoutRegistration: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { registrationAllowed: false }
}}
/>
)
};
export const WithoutRememberMe: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { rememberMe: false }
}}
/>
)
};
export const WithoutPasswordReset: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { resetPasswordAllowed: false }
}}
/>
)
};
export const WithEmailAsUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { loginWithEmailAllowed: false }
}}
/>
)
};
export const WithPresetUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
login: { username: "max.mustermann@mail.com" }
}}
/>
)
};
export const WithImmutablePresetUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
auth: {
attemptedUsername: "max.mustermann@mail.com",
showUsername: true
},
usernameHidden: true,
message: {
type: "info",
summary: "Please re-authenticate to continue"
}
}}
/>
)
};
export const WithSocialProviders: Story = {
render: () => (
<KcPageStory
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
},
{
loginUrl: "facebook",
alias: "facebook",
providerId: "facebook",
displayName: "Facebook",
iconClasses: "fa fa-facebook"
},
{
loginUrl: "instagram",
alias: "instagram",
providerId: "instagram",
displayName: "Instagram",
iconClasses: "fa fa-instagram"
},
{
loginUrl: "twitter",
alias: "twitter",
providerId: "twitter",
displayName: "Twitter",
iconClasses: "fa fa-twitter"
},
{
loginUrl: "yandex",
alias: "yandex",
providerId: "yandex",
displayName: "Yandex",
iconClasses: "fa fa-yandex"
},
{
loginUrl: "vk",
alias: "vk",
providerId: "vk",
displayName: "VK",
iconClasses: "fa fa-vk"
},
{
loginUrl: "linkedin",
alias: "linkedin",
providerId: "linkedin",
displayName: "LinkedIn",
iconClasses: "fa fa-linkedin"
},
{
loginUrl: "stackoverflow",
alias: "stackoverflow",
providerId: "stackoverflow",
displayName: "Stackoverflow",
iconClasses: "fa fa-stack-overflow"
},
{
loginUrl: "github",
alias: "github",
providerId: "github",
displayName: "Github",
iconClasses: "fa fa-github"
},
{
loginUrl: "gitlab",
alias: "gitlab",
providerId: "gitlab",
displayName: "Gitlab",
iconClasses: "fa fa-gitlab"
},
{
loginUrl: "bitbucket",
alias: "bitbucket",
providerId: "bitbucket",
displayName: "Bitbucket",
iconClasses: "fa fa-bitbucket"
},
{
loginUrl: "paypal",
alias: "paypal",
providerId: "paypal",
displayName: "PayPal",
iconClasses: "fa fa-paypal"
},
{
loginUrl: "openshift",
alias: "openshift",
providerId: "openshift",
displayName: "OpenShift",
iconClasses: "fa fa-cloud"
}
]
}
}}
/>
)
};
export const WithoutPasswordField: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { password: false }
}}
/>
)
};
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "The time allotted for the connection has elapsed.<br/>The login process will restart from the beginning.",
type: "error"
}
}}
/>
)
};
export const WithOneSocialProvider: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
}
]
}
}}
/>
)
};
export const WithTwoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
}
]
}
}}
/>
)
};
export const WithNoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: []
}
}}
/>
)
};
export const WithMoreThanTwoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
},
{
loginUrl: "facebook",
alias: "facebook",
providerId: "facebook",
displayName: "Facebook",
iconClasses: "fa fa-facebook"
},
{
loginUrl: "twitter",
alias: "twitter",
providerId: "twitter",
displayName: "Twitter",
iconClasses: "fa fa-twitter"
},
{
loginUrl: "yandex",
alias: "yandex",
providerId: "yandex",
displayName: "Yandex",
iconClasses: "fa fa-yandex"
},
{
loginUrl: "vk",
alias: "vk",
providerId: "vk",
displayName: "VK",
iconClasses: "fa fa-vk"
}
]
}
}}
/>
)
};
export const WithSocialProvidersAndWithoutRememberMe: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
}
]
},
realm: { rememberMe: false }
}}
/>
)
};
/**
* WithAuthPassKey:
* - Purpose: Test usage of Sign In With Pass Key integration
* - Scenario: Simulates a scenario where the `Sign In with Passkey` button is rendered below `Sign In` button.
* - Key Aspect: Ensure that it is displayed correctly.
*/
export const WithAuthPassKey: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
url: {
loginAction: "/mock-login-action"
},
enableWebAuthnConditionalUI: true
}}
/>
)
};
+127
View File
@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-otp.ftl" });
const meta = {
title: "login/login-otp.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* MultipleOtpCredentials:
* - Purpose: Tests the behavior when the user has multiple OTP credentials to choose from.
* - Scenario: Simulates the scenario where the user is presented with multiple OTP credentials and must select one to proceed.
* - Key Aspect: Ensures that multiple OTP credentials are listed and selectable, and the correct credential is selected by default.
*/
export const MultipleOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: [
{ id: "credential1", userLabel: "Device 1" },
{ id: "credential2", userLabel: "Device 2" },
{ id: "credential3", userLabel: "Device 3" },
{ id: "credential4", userLabel: "Device 4" },
{ id: "credential5", userLabel: "Device 5" },
{ id: "credential6", userLabel: "Device 6" }
],
selectedCredentialId: "credential1"
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};
/**
* WithOtpError:
* - Purpose: Tests the behavior when an error occurs with the OTP field (e.g., invalid OTP code).
* - Scenario: Simulates an invalid OTP code scenario where an error message is displayed.
* - Key Aspect: Ensures that the OTP input displays error messages correctly and the error is visible.
*/
export const WithOtpError: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: []
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: (field: string) => field === "totp",
get: () => "Invalid OTP code"
}
}}
/>
)
};
/**
* NoOtpCredentials:
* - Purpose: Tests the behavior when no OTP credentials are provided for the user.
* - Scenario: Simulates the scenario where the user is not presented with any OTP credentials, and only the OTP input is displayed.
* - Key Aspect: Ensures that the component handles cases where there are no user OTP credentials, and the user is only prompted for the OTP code.
*/
export const NoOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: []
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};
/**
* WithErrorAndMultipleOtpCredentials:
* - Purpose: Tests behavior when there is both an error in the OTP field and multiple OTP credentials.
* - Scenario: Simulates the case where the user has multiple OTP credentials and encounters an error with the OTP input.
* - Key Aspect: Ensures that the component can handle both multiple OTP credentials and display an error message simultaneously.
*/
export const WithErrorAndMultipleOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: [
{ id: "credential1", userLabel: "Device 1" },
{ id: "credential2", userLabel: "Device 2" }
],
selectedCredentialId: "credential1"
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: (field: string) => field === "totp",
get: () => "Invalid OTP code"
}
}}
/>
)
};
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-passkeys-conditional-authenticate.ftl" });
const meta = {
title: "login/login-passkeys-conditional-authenticate.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-reset-password.ftl" });
const meta = {
title: "login/login-reset-password.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithEmailAsUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
loginWithEmailAllowed: true,
registrationEmailAsUsername: true
}
}}
/>
)
};
/**
* WithUsernameError:
* - Purpose: Tests behavior when an error occurs with the username input (e.g., invalid username).
* - Scenario: The component displays an error message next to the username input field.
* - Key Aspect: Ensures the username input shows error messages when validation fails.
*/
export const WithUsernameError: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
loginWithEmailAllowed: false,
registrationEmailAsUsername: false,
duplicateEmailsAllowed: false
},
url: {
loginAction: "/mock-login-action",
loginUrl: "/mock-login-url"
},
messagesPerField: {
existsError: (field: string) => field === "username",
get: () => "Invalid username"
},
auth: {
attemptedUsername: "invalid_user"
}
}}
/>
)
};
@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-verify-email.ftl" });
const meta = {
title: "login/login-verify-email.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "You need to verify your email to activate your account.",
type: "warning"
},
user: {
email: "john.doe@gmail.com"
}
}}
/>
)
};
/**
* WithSuccessMessage:
* - Purpose: Tests when the email verification is successful, and the user receives a confirmation message.
* - Scenario: The component renders a success message instead of a warning or error.
* - Key Aspect: Ensures the success message is displayed correctly when the email is successfully verified.
*/
export const WithSuccessMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "Your email has been successfully verified.",
type: "success"
},
user: {
email: "john.doe@gmail.com"
},
url: {
loginAction: "/mock-login-action"
}
}}
/>
)
};
/**
* WithErrorMessage:
* - Purpose: Tests when there is an error during the email verification process.
* - Scenario: The component renders an error message indicating the email verification failed.
* - Key Aspect: Ensures the error message is shown correctly when the verification process encounters an issue.
*/
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "There was an error verifying your email. Please try again.",
type: "error"
},
user: {
email: "john.doe@gmail.com"
},
url: {
loginAction: "/mock-login-action"
}
}}
/>
)
};
/**
* WithInfoMessage:
* - Purpose: Tests when the user is prompted to verify their email without any urgency.
* - Scenario: The component renders with an informational message for email verification.
* - Key Aspect: Ensures the informational message is displayed properly.
*/
export const WithInfoMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "Please verify your email to continue using our services.",
type: "info"
},
user: {
email: "john.doe@gmail.com"
},
url: {
loginAction: "/mock-login-action"
}
}}
/>
)
};
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "qr-login-canceled.ftl" });
const meta = {
title: "login/qr-login-canceled.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
+26
View File
@@ -0,0 +1,26 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Alert, AlertTitle } from "@/components/ui/alert";
export default function QrLoginCanceled(props: PageProps<Extract<KcContext, { pageId: "qr-login-canceled.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { msg } = i18n;
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayInfo={false}
headerNode={msg("consentDenied")}
>
<div id="kc-qr-canceled-message" className={classes?.kcFormClass}>
<Alert variant="destructive" className="w-full">
<AlertTitle>{msg("consentDenied")}</AlertTitle>
</Alert>
</div>
</Template>
);
}
+89
View File
@@ -0,0 +1,89 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "qr-login-scan.ftl" });
const meta = {
title: "login/qr-login-scan.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
const qrCodePlaceholder =
"iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAABHNCSVQICAgIfAhkiAAAAElJREFUOI1jZGBgYJj9X/U/IwMDw3wmBgaG+YwMDAwM8xkYGBgY5jMwMDAwzGdgYGBgmM/AwMDAMB8HBwb5DAwMDAzIZmBgYGBgAAD9/wX9eWNG0QAAAABJRU5ErkJggg==";
export const Default: Story = {
render: () => (
<KcPageStory
kcContext={{
QRauthImage: qrCodePlaceholder,
QRauthExecId: "8f16a04f-6610-4555-8607-4bf88f4a2d92",
QRauthToken: "link",
tabId: "GvAAhBUeVqw",
alignment: "Center",
refreshRate: 15,
url: {
loginAction: "/mock-login-action"
}
}}
/>
)
};
export const WithShortCode: Story = {
render: () => (
<KcPageStory
kcContext={{
QRauthImage: qrCodePlaceholder,
QRauthExecId: "8f16a04f-6610-4555-8607-4bf88f4a2d92",
QRauthToken: "link",
tabId: "GvAAhBUeVqw",
alignment: "Center",
refreshRate: 0,
ShortCode: "123456",
ShortCodeLink: "#",
url: {
loginAction: "/mock-login-action"
}
}}
/>
)
};
export const WithoutAutoSubmit: Story = {
render: () => (
<KcPageStory
kcContext={{
QRauthImage: qrCodePlaceholder,
QRauthExecId: "8f16a04f-6610-4555-8607-4bf88f4a2d92",
QRauthToken: "link",
tabId: "GvAAhBUeVqw",
alignment: "Center",
refreshRate: 0,
url: {
loginAction: "/mock-login-action"
}
}}
/>
)
};
export const LoadingWithoutQrCode: Story = {
render: () => (
<KcPageStory
kcContext={{
QRauthExecId: "8f16a04f-6610-4555-8607-4bf88f4a2d92",
QRauthToken: "link",
tabId: "GvAAhBUeVqw",
alignment: "Center",
refreshRate: 0,
url: {
loginAction: "/mock-login-action"
}
}}
/>
)
};
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from "react";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Button } from "@/components/ui/button";
export default function QrLoginScan(props: PageProps<Extract<KcContext, { pageId: "qr-login-scan.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { url, QRauthImage, QRauthExecId, QRauthToken, tabId, alignment, ShortCode, ShortCodeLink, refreshRate = 15 } = kcContext;
const { msg, msgStr } = i18n;
const formRef = useRef<HTMLFormElement>(null);
const [showShortCode, setShowShortCode] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
formRef.current?.requestSubmit();
}, refreshRate * 1000);
return () => clearTimeout(timer);
}, [refreshRate]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("qr_code_originated") === "true") {
const el = document.getElementById("com-hadleyso-qr-auth");
if (el) el.style.display = "none";
}
}, []);
const qrContainerStyle: React.CSSProperties = {
width: "45%",
marginLeft: alignment === "Center" ? "auto" : alignment === "Right" ? "auto" : undefined,
marginRight: alignment === "Center" || alignment === "Left" ? "auto" : undefined
};
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayInfo={false}
headerNode={msg("doQrCodeLogin")}
>
<div id="com-hadleyso-qr-auth" className={classes?.kcFormClass}>
<div
id="com-hadleyso-qr-auth-js-target"
className="kcQrCodeContainer"
style={qrContainerStyle}
onClick={() => formRef.current?.requestSubmit()}
>
<span className="kcQrTokenHidden">{QRauthToken}</span>
{QRauthImage ? (
<img
id="com-hadleyso-qr-auth-qr-code"
src={`data:image/png;base64,${QRauthImage}`}
alt={msgStr("ext-qr-code-login-display-name")}
className="kcQrCodeImage"
/>
) : (
<div className="kcQrCodePlaceholder">{msg("qrLoginLoading")}</div>
)}
</div>
{tabId && (
<p className="kcQrSessionClass">
<b>{msg("qrLoginSessionLabel")}</b> {tabId}
</p>
)}
{ShortCode && (
<p id="com-hadleyso-qr-auth-short-zone" className="kcQrShortCodeZone">
<Button
variant="link"
size="sm"
id="com-hadleyso-qr-auth-short-start"
onClick={event => {
setShowShortCode(true);
event.preventDefault();
}}
>
{msg("CannotScan")}
</Button>{" "}
|{" "}
<Button variant="link" size="sm" asChild>
<a href={ShortCodeLink}>{msg("UseShortCode")}</a>
</Button>
</p>
)}
{ShortCode && showShortCode && (
<p id="com-hadleyso-qr-auth-short-message" className="kcQrShortCodeMessage">
{msg("doShortCodeSteps")}
<br />
<br />
<b>{msg("doShortCode")}:</b> {ShortCode}
</p>
)}
<form
ref={formRef}
id={`com-hadleyso-qrcode-${QRauthExecId ?? "default"}`}
className={classes?.kcFormClass}
action={url.loginAction}
method="post"
>
<input type="hidden" name="authenticationExecution" value={QRauthExecId ?? ""} />
<Button type="submit" size="lg">
{msgStr("doLogIn")}
</Button>
</form>
</div>
</Template>
);
}
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "qr-login-short-start.ftl" });
const meta = {
title: "login/qr-login-short-start.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
+50
View File
@@ -0,0 +1,50 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function QrLoginShortStart(props: PageProps<Extract<KcContext, { pageId: "qr-login-short-start.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { msg, msgStr } = i18n;
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayInfo={false}
headerNode={msg("doShortCodeLogin")}
>
<form id="com-hadleyso-qrcode-short" className={classes?.kcFormClass} method="post">
<div className={classes?.kcFormGroupClass}>
<p className="kcQrShortCodeInfoClass">{msg("doShortCodeInfo")}</p>
<Label htmlFor="shortCode" className={classes?.kcLabelClass}>
{msg("doShortCode")}
</Label>
<Input
id="shortCode"
name="shortCode"
type="number"
inputMode="numeric"
pattern="\d{6}"
min={100000}
max={999999}
autoFocus
required
className={classes?.kcInputClass}
placeholder={msgStr("doShortCode")}
/>
</div>
<div className={classes?.kcFormGroupClass}>
<Button type="submit">{msgStr("doSubmit")}</Button>
</div>
</form>
</Template>
);
}
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "qr-login-success.ftl" });
const meta = {
title: "login/qr-login-success.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
+26
View File
@@ -0,0 +1,26 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Alert, AlertTitle } from "@/components/ui/alert";
export default function QrLoginSuccess(props: PageProps<Extract<KcContext, { pageId: "qr-login-success.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { msg } = i18n;
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayInfo={false}
headerNode={msg("successQrCodeLoginTitle")}
>
<div id="kc-qr-success-message" className={classes?.kcFormClass}>
<Alert className="w-full">
<AlertTitle>{msg("successQrCodeLoginMessage")}</AlertTitle>
</Alert>
</div>
</Template>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "qr-login-verify.ftl" });
const meta = {
title: "login/qr-login-verify.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<KcPageStory
kcContext={{
ua_device: "Desktop",
ua_os: "macOS",
ua_agent: "Chrome",
local_localized: "English",
tabId: "GvAAhBUeVqw",
approveURL: "#approve",
rejectURL: "#reject"
}}
/>
)
};
+49
View File
@@ -0,0 +1,49 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Button } from "@/components/ui/button";
import { Alert, AlertTitle } from "@/components/ui/alert";
export default function QrLoginVerify(props: PageProps<Extract<KcContext, { pageId: "qr-login-verify.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { ua_device, ua_os, ua_agent, local_localized, tabId, approveURL, rejectURL } = kcContext;
const { msg } = i18n;
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayInfo={false}
headerNode={msg("doQrCodeVerify")}
>
<div id="kc-qr-verify-form" className={classes?.kcFormClass}>
<Alert variant="destructive" className="mb-4 w-full">
<AlertTitle>{msg("doQrCodeWarning")}</AlertTitle>
</Alert>
<p className="kcQrDeviceInfoClass">
You are authorizing a session on a <b>{ua_device}</b> running <b>{ua_os}</b> / <b>{ua_agent}</b> in locale{" "}
<b>{local_localized}</b>.
</p>
{tabId && (
<p className="kcQrSessionClass">
Session: <b>{tabId}</b>
</p>
)}
<div className="kcQrVerifyButtonsClass">
<Button asChild size="lg">
<a href={approveURL}>{msg("doAccept")}</a>
</Button>
<Button variant="secondary" asChild size="lg">
<a href={rejectURL}>{msg("doDecline")}</a>
</Button>
</div>
</div>
</Template>
);
}
@@ -0,0 +1,110 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "select-authenticator.ftl" });
const meta = {
title: "login/select-authenticator.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithDifferentAuthenticationMethods: Story = {
render: () => (
<KcPageStory
kcContext={{
auth: {
authenticationSelections: [
{
authExecId: "25697c4e-0c80-4f2c-8eb7-2c16347e8e8d",
displayName: "auth-username-password-form-display-name",
helpText: "auth-username-password-form-help-text",
iconCssClass: "kcAuthenticatorPasswordClass"
},
{
authExecId: "4cb60872-ce0d-4c8f-a806-e651ed77994b",
displayName: "webauthn-passwordless-display-name",
helpText: "webauthn-passwordless-help-text",
iconCssClass: "kcAuthenticatorWebAuthnPasswordlessClass"
},
{
authExecId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
displayName: "ext-qr-code-login-display-name",
helpText: "ext-qr-code-login-help-text",
iconCssClass: "kcAuthenticatorDefaultClass"
}
]
}
}}
/>
)
};
export const WithRealmTranslations: Story = {
render: () => (
<KcPageStory
kcContext={{
auth: {
authenticationSelections: [
{
authExecId: "f0c22855-eda7-4092-8565-0c22f77d2ffb",
displayName: "home-idp-discovery-display-name",
helpText: "home-idp-discovery-help-text",
iconCssClass: "kcAuthenticatorDefaultClass"
},
{
authExecId: "20456f5a-8b2b-45f3-98e0-551dcb27e3e1",
displayName: "identity-provider-redirctor-display-name",
helpText: "identity-provider-redirctor-help-text",
iconCssClass: "kcAuthenticatorDefaultClass"
},
{
authExecId: "eb435db9-474e-473a-8da7-c184fa510b96",
displayName: "auth-username-password-form-display-name",
helpText: "auth-username-password-help-text",
iconCssClass: "kcAuthenticatorPasswordClass"
}
]
},
"x-keycloakify": {
messages: {
"home-idp-discovery-display-name": "Home identity provider",
"home-idp-discovery-help-text":
"Sign in via your home identity provider which will be automatically determined based on your provided email address.",
"identity-provider-redirctor-display-name": "Identity Provider Redirector",
"identity-provider-redirctor-help-text": "Sign in via your identity provider.",
"auth-username-password-help-text": "Sign in via your username and password."
}
}
}}
/>
)
};
/**
* WithoutAuthenticationSelections:
* - Purpose: Tests when no authentication methods are available for selection.
* - Scenario: The component renders without any authentication options, providing a default message or fallback.
* - Key Aspect: Ensures that the component gracefully handles the absence of available authentication methods.
*/
export const WithoutAuthenticationSelections: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
auth: {
authenticationSelections: [] // No authentication methods available
}
}}
/>
)
};
@@ -0,0 +1,158 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "webauthn-authenticate.ftl" });
const meta = {
title: "login/webauthn-authenticate.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithMultipleAuthenticators:
* - Purpose: Tests when multiple WebAuthn authenticators are available for selection.
* - Scenario: The component renders multiple authenticators, allowing the user to choose between them.
* - Key Aspect: Ensures that the available authenticators are displayed, and the user can select one.
*/
export const WithMultipleAuthenticators: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
authenticators: {
authenticators: [
{
credentialId: "authenticator-1",
label: "Security Key 1",
transports: {
iconClass: "kcAuthenticatorUsbIcon",
displayNameProperties: ["USB"]
},
createdAt: "2023-01-01"
},
{
credentialId: "authenticator-2",
label: "Security Key 2",
transports: {
iconClass: "kcAuthenticatorNfcIcon",
displayNameProperties: ["NFC"]
},
createdAt: "2023-02-01"
}
]
},
shouldDisplayAuthenticators: true
}}
/>
)
};
/**
* WithSingleAuthenticator:
* - Purpose: Tests when only one WebAuthn authenticator is available.
* - Scenario: The component renders the WebAuthn form with a single available authenticator.
* - Key Aspect: Ensures the form renders correctly when there is only one authenticator available.
*/
export const WithSingleAuthenticator: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
authenticators: {
authenticators: [
{
credentialId: "authenticator-1",
label: "My Security Key",
transports: {
iconClass: "kcAuthenticatorUsbIcon",
displayNameProperties: ["USB"]
},
createdAt: "2023-01-01"
}
]
},
shouldDisplayAuthenticators: true
}}
/>
)
};
/**
* WithErrorDuringAuthentication:
* - Purpose: Tests the behavior when an error occurs during WebAuthn authentication.
* - Scenario: The component renders with an error message displayed to the user.
* - Key Aspect: Ensures the form handles authentication errors and displays a relevant message.
*/
export const WithErrorDuringAuthentication: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
authenticators: {
authenticators: [
{
credentialId: "authenticator-1",
label: "My Security Key",
transports: {
iconClass: "kcAuthenticatorUsbIcon",
displayNameProperties: ["USB"]
},
createdAt: "2023-01-01"
}
]
},
shouldDisplayAuthenticators: true,
message: {
summary: "An error occurred during WebAuthn authentication.",
type: "error"
}
}}
/>
)
};
/**
* WithJavaScriptDisabled:
* - Purpose: Tests the behavior when JavaScript is disabled or not functioning.
* - Scenario: The component renders a fallback message prompting the user to enable JavaScript for WebAuthn authentication.
* - Key Aspect: Ensures the form provides a clear message when JavaScript is required but unavailable.
*/
export const WithJavaScriptDisabled: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
authenticators: {
authenticators: [
{
credentialId: "authenticator-1",
label: "My Security Key",
transports: {
iconClass: "kcAuthenticatorUsbIcon",
displayNameProperties: ["USB"]
},
createdAt: "2023-01-01"
}
]
},
shouldDisplayAuthenticators: true
}}
/>
)
};
@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "webauthn-register.ftl" });
const meta = {
title: "login/webauthn-register.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithRetryAvailable:
* - Purpose: Tests when the user is allowed to retry WebAuthn registration after a failure.
* - Scenario: The component renders the form with a retry option.
* - Key Aspect: Ensures the retry functionality is available and the form allows the user to retry.
*/
export const WithRetryAvailable: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
isSetRetry: true,
isAppInitiatedAction: false
}}
/>
)
};
/**
* WithErrorDuringRegistration:
* - Purpose: Tests when an error occurs during WebAuthn registration.
* - Scenario: The component displays an error message related to WebAuthn registration failure.
* - Key Aspect: Ensures the error message is displayed correctly, informing the user of the registration failure.
*/
export const WithErrorDuringRegistration: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
isSetRetry: false,
isAppInitiatedAction: false,
message: {
summary: "An error occurred during WebAuthn registration. Please try again.",
type: "error"
}
}}
/>
)
};
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -18,7 +18,13 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
+15 -3
View File
@@ -1,13 +1,25 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { keycloakify } from "keycloakify/vite-plugin"; import { keycloakify } from "keycloakify/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
keycloakify({ keycloakify({
accountThemeImplementation: "none" accountThemeImplementation: "none",
}) keycloakVersionTargets: {
] "22-to-25": "zeusina-keycloak.jar",
"all-other-versions": false
}
}),
tailwindcss()
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src")
}
}
}); });
+1921 -828
View File
File diff suppressed because it is too large Load Diff