feat: initial theme for sr2 cloud
Some checks failed
ci / build_and_publish (push) Failing after 21s

This commit is contained in:
Iain Learmonth 2026-02-24 12:34:36 +00:00
parent 2a4c13268b
commit b278d97f3c
10 changed files with 7592 additions and 56 deletions

View file

@ -0,0 +1,28 @@
name: ci
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build_and_publish:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:runner-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm install
- run: sudo apt update && sudo apt install -y maven
- run: npm run build-keycloak-theme-cloud
- run: mv dist_keycloak/keycloak-theme-cloud-for-kc-all-other-versions.jar sr2-theme.jar
- run: sha256sum sr2-theme.jar > sr2-theme.jar.sha256
- run: |
curl -H "Authorization: token ${{ secrets.PACKAGE_TOKEN }}" -X DELETE "https://guardianproject.dev/api/packages/sr2/generic/keycloak-theme-cloud/latest"
- run: |
curl -H "Authorization: token ${{ secrets.PACKAGE_TOKEN }}" -X PUT --upload-file sr2-theme.jar https://guardianproject.dev/api/packages/sr2/generic/keycloak-theme-cloud/latest/sr2-cloud-theme.jar
- run: |
curl -H "Authorization: token ${{ secrets.PACKAGE_TOKEN }}" -X PUT --upload-file sr2-theme.jar.sha256 https://guardianproject.dev/api/packages/sr2/generic/keycloak-theme-cloud/latest/sr2-cloud-theme.jar.sha256

View file

@ -1,55 +0,0 @@
name: ci
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: bahmutov/npm-install@v1
- run: npm run build-keycloak-theme
check_if_version_upgraded:
name: Check if version upgrade
if: github.event_name == 'push'
runs-on: ubuntu-latest
needs: test
outputs:
from_version: ${{ steps.step1.outputs.from_version }}
to_version: ${{ steps.step1.outputs.to_version }}
is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }}
is_pre_release: ${{steps.step1.outputs.is_pre_release }}
steps:
- uses: garronej/ts-ci@v2.1.5
id: step1
with:
action_name: is_package_json_version_upgraded
branch: ${{ github.head_ref || github.ref }}
create_github_release:
runs-on: ubuntu-latest
needs: check_if_version_upgraded
if: needs.check_if_version_upgraded.outputs.is_upgraded_version == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: bahmutov/npm-install@v1
- run: npm run build-keycloak-theme
- uses: softprops/action-gh-release@v2
with:
name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }}
tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }}
target_commitish: ${{ github.head_ref || github.ref }}
generate_release_notes: true
draft: false
prerelease: ${{ needs.check_if_version_upgraded.outputs.is_pre_release == 'true' }}
files: dist_keycloak/keycloak-theme-*.jar
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View file

@ -58,3 +58,4 @@ jspm_packages
# build output of `jsx-email`
/.rendered
.idea/

6964
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

BIN
public/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
public/logo-side.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View file

@ -3,7 +3,8 @@ import type { ClassKey } from "keycloakify/login";
import type { KcContext } from "./KcContext";
import { useI18n } from "./i18n";
import DefaultPage from "keycloakify/login/DefaultPage";
import Template from "keycloakify/login/Template";
import Template from "./Template";
import "./main.css";
const UserProfileFormFields = lazy(
() => import("keycloakify/login/UserProfileFormFields")
);

185
src/login/Template.tsx Normal file
View file

@ -0,0 +1,185 @@
import { useEffect } from "react";
import { clsx } from "keycloakify/tools/clsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { TemplateProps } from "keycloakify/login/TemplateProps";
import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/login/Template.useInitialize";
import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext";
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
displayInfo = false,
displayMessage = true,
displayRequiredFields = false,
headerNode,
socialProvidersNode = null,
infoNode = null,
documentTitle,
bodyClassName,
kcContext,
i18n,
doUseDefaultCss,
classes,
children
} = 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);
}, []);
useSetClassName({
qualifiedName: "html",
className: kcClsx("kcHtmlClass")
});
useSetClassName({
qualifiedName: "body",
className: bodyClassName ?? kcClsx("kcBodyClass")
});
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
if (!isReadyToRender) {
return null;
}
return (
<div className={kcClsx("kcLoginClass")}>
<div id="kc-header" className={kcClsx("kcHeaderClass")}>
<div id="kc-header-wrapper" className={kcClsx("kcHeaderWrapperClass")}>
{msg("loginTitleHtml", realm.displayNameHtml)}
</div>
</div>
<div className={kcClsx("kcFormCardClass")}>
<header className={kcClsx("kcFormHeaderClass")}>
{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) => (
<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>
)}
{(() => {
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")}></i>
<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;
})()}
</header>
<div id="kc-content">
<div id="kc-content-wrapper">
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<div
className={clsx(
`alert-${message.type}`,
kcClsx("kcAlertClass"),
`pf-m-${message?.type === "error" ? "danger" : message.type}`
)}
>
<div className="pf-c-alert__icon">
{message.type === "success" && <span className={kcClsx("kcFeedbackSuccessIcon")}></span>}
{message.type === "warning" && <span className={kcClsx("kcFeedbackWarningIcon")}></span>}
{message.type === "error" && <span className={kcClsx("kcFeedbackErrorIcon")}></span>}
{message.type === "info" && <span className={kcClsx("kcFeedbackInfoIcon")}></span>}
</div>
<span
className={kcClsx("kcAlertTitleClass")}
dangerouslySetInnerHTML={{
__html: kcSanitize(message.summary)
}}
/>
</div>
)}
{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
id="try-another-way"
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].requestSubmit();
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>
</div>
</div>
</div>
);
}

33
src/login/main.css Normal file
View file

@ -0,0 +1,33 @@
body {
margin-bottom: 50px;
}
body.kcBodyClass {
background: url(/background.jpg) no-repeat bottom center fixed;
background-size: cover;
}
.kcFormCardClass {
border-image: linear-gradient(to right, #01486f, #00a870) 1;
border-width: 4px;
border-style: solid;
padding: 5px;
}
.kcHeaderWrapperClass {
display: none;
}
.kcFormHeaderClass h1:before {
content: "";
display: inline-block;
background: url(/logo-side.png) no-repeat center center;
background-size: contain;
width: 100%;
aspect-ratio: 1360/338;
margin-bottom: 30px;
}
#kc-form, #kc-form-webauthn {
margin-bottom: 10px;
}

View file

@ -0,0 +1,379 @@
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: "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"
}
]
}
}}
/>
)
};
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
}}
/>
)
};