Initial pub.solar commit

This commit is contained in:
b12f 2024-11-13 17:58:58 +01:00
parent cbed1f320b
commit 234a648149
Signed by: b12f
GPG key ID: 729956E1124F8F26
45 changed files with 4279 additions and 1055 deletions

114
flake.lock Normal file
View file

@ -0,0 +1,114 @@
{
"nodes": {
"devshell": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": ["nixpkgs"]
},
"locked": {
"lastModified": 1705332421,
"narHash": "sha256-USpGLPme1IuqG78JNqSaRabilwkCyHmVWY0M9vYyqEA=",
"owner": "numtide",
"repo": "devshell",
"rev": "83cb93d6d063ad290beee669f4badf9914cc16ec",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1707092692,
"narHash": "sha256-ZbHsm+mGk/izkWtT4xwwqz38fdlwu7nUUKXTOmm4SyE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "faf912b086576fd1a15fca610166c98d47bc667e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

54
flake.nix Normal file
View file

@ -0,0 +1,54 @@
{
description = "Keycloak pub.solar theme";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
devshell.url = "github:numtide/devshell";
devshell.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, flake-utils, devshell, nixpkgs }:
let
pkgs = import nixpkgs {
overlays = [
devshell.overlay
(import ./overlay.nix)
];
};
in
flake-utils.lib.simpleFlake {
inherit self nixpkgs;
name = "keycloak-theme-pub-solar";
overlay = ./overlay.nix;
preOverlays = [ devshell.overlays.default ];
shell = { pkgs }:
let
google-font-downloader = with pkgs; writeShellScriptBin "google-font-downloader" ''
echo "Attempting to fetch $1"
${nodejs}/bin/npx google-font-downloader -- $1
rm ./common/resources/scss/typography.scss
echo "/* To regenerate this file, run $ google-font-downloader '$1' */" >> ./common/resources/scss/typography.scss
echo "" >> ./common/resources/scss/typography.scss
cat ./google-fonts-*.css >> ./common/resources/scss/typography.scss
rm ./google-fonts-*.css
mv fonts ./common/resources/fonts
'';
in
pkgs.devshell.mkShell {
imports = [ ];
# Add additional packages you'd like to be available in your devshell
# PATH here
devshell.packages = [
google-font-downloader
pkgs.nodejs
pkgs.nodePackages.typescript
pkgs.nodePackages.typescript-language-server
pkgs.nodePackages.vue-language-server
];
};
};
}

22
overlay.nix Normal file
View file

@ -0,0 +1,22 @@
final: prev:
let
pkgs = final;
version = "0.1";
keycloak-account-v1 = prev.fetchMavenArtifact {
artifactId = "keycloak-account-v1";
groupId = "io.phasetwo.keycloak";
inherit version;
hash = "sha256-t4kuc5ZieqsC06/alFN0W1ktORuk36TIgKXfrmBtesA=";
};
in
{
# this key should be the same as the simpleFlake name attribute.
keycloak-theme-pub-solar = {
keycloak-theme-pub-solar = import ./pkgs/keycloak-theme-pub-solar.nix { inherit pkgs; };
keycloak = prev.keycloak.overrideAttrs (finalAttrs: previousAttrs: {
installPhase = previousAttrs.installPhase + ''
ln -s ${keycloak-account-v1}/share/java/keycloak-account-v1-${version}.jar $out/providers/keycloak-account-v1-${version}.jar
'';
});
};
}

View file

@ -22,7 +22,6 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"storybook": "^8.1.10",
"@storybook/react": "^8.1.10",
"@storybook/react-vite": "^8.1.10",
"@types/react": "^18.2.43",
@ -35,6 +34,8 @@
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-storybook": "^0.8.0",
"prettier": "3.3.1",
"sass": "^1.80.7",
"storybook": "^8.1.10",
"typescript": "^5.2.2",
"vite": "^5.0.8"
},

119
public/pub.solar.svg Normal file
View file

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
id="Layer_3"
data-name="Layer 3"
viewBox="0 0 275.3 276.37"
version="1.1"
sodipodi:docname="pub.solar.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
id="namedview226"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="4.3962803"
inkscape:cx="95.762774"
inkscape:cy="149.33079"
inkscape:window-width="2560"
inkscape:window-height="1380"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_3" />
<defs
id="defs197">
<style
id="style195">.cls-1,.cls-2{fill:#ed1c24;}.cls-1{stroke:#ed1c24;stroke-width:6.89px;}.cls-1,.cls-2,.cls-4{stroke-miterlimit:10;}.cls-2{stroke:#fff;stroke-width:4.72px;}.cls-3{fill:#fff;}.cls-4{stroke:#000;stroke-width:4.58px;}</style>
</defs>
<title
id="title199">PubSolar logo</title>
<path
class="cls-1"
d="M362.85,272.68v11.78l-4.39,1.84a18.38,18.38,0,0,0-11.24,15.8l-.59,9.43-7.92.61a18.38,18.38,0,0,0-15.53,11.22l-2.47,5.9H309.07a18.38,18.38,0,0,0-14.13,6.63l-6.8,8.17-10.58-3.17a18.38,18.38,0,0,0-14.89,1.95l-9.84,6-8.65-5.75a18.38,18.38,0,0,0-15.75-2.2l-10,3.17-5.88-7a18.38,18.38,0,0,0-15.43-6.51l-10.24.76-4.62-8.75a18.38,18.38,0,0,0-14.22-9.69l-9.7-1.08-1.89-10.65a18.38,18.38,0,0,0-11.06-13.77L134.7,283l.93-11.32a18.38,18.38,0,0,0-7-16l-6.52-5.06,3.55-8.77a18.38,18.38,0,0,0-1.77-17.13l-5.74-8.57,6.06-8a18.38,18.38,0,0,0,2.54-17.63l-3.93-10.34,5.12-2.87a18.38,18.38,0,0,0,9.31-17.78l-1.12-11.75,8.18-2.64a18.38,18.38,0,0,0,12.67-16l.75-9.5h6.44a18.38,18.38,0,0,0,17.59-13L184,99.13l12.19,1.25a18.38,18.38,0,0,0,17.27-8.25l4.11-6.3,8.21,3.79a18.38,18.38,0,0,0,20.12-3.14l5.85-5.36L261,88.21a18.38,18.38,0,0,0,18.23,2.32L287.76,87l6,7.67a18.38,18.38,0,0,0,15.32,7l11.25-.51,3.21,7.56a18.38,18.38,0,0,0,15.29,11.12l9.33.83.48,7.39A18.38,18.38,0,0,0,358.1,143l9.37,5.18-.71,11.41a18.38,18.38,0,0,0,7.43,15.93l7.14,5.26-3.79,10.66a18.38,18.38,0,0,0,2,16.27l5.57,8.45-6.46,10.31a18.38,18.38,0,0,0-2.11,14.77l2.66,9.38-9.63,7.93A18.38,18.38,0,0,0,362.85,272.68Z"
transform="translate(-113.88 -76.62)"
id="path201" />
<circle
class="cls-2"
cx="137.72"
cy="138.48"
r="117.79"
id="circle203" />
<path
class="cls-3"
d="M326.34,141.78A105.72,105.72,0,0,0,181.56,295.61,105.7,105.7,0,1,1,326.34,141.78Z"
transform="translate(-113.88 -76.62)"
id="path205" />
<path
class="cls-3"
d="m 180.73,231.12 c 0.57,1.71 6.47,20.7 10,30 3.53,9.3 7.59,16.9 12.87,17.74 5.28,0.84 10.64,-7.56 16.9,-7.78 8.76,-0.31 13.68,-1 18.13,-8.12 3.56,-5.67 9.9,1.76 25.87,-7.49 8.71,-5 26.56,-4.08 43.4,-11.91 20.26,-9.42 6.82,-12 6.83,-24 0,-3.84 3.66,-4.88 9.42,-7.17 3.82,-1.52 9.75,-4.8 8.63,-8.92 -1.24,-4.55 -0.79,-6.28 -5.2,-7.93 -8.95912,-3.35701 -15.41112,0.51436 -20.81894,2.56685 -7.90305,3.13369 -9.25397,-5.78637 -14.68734,-7.38637 -3.84252,-1.13153 -10.81538,-2.53029 -12.21968,-9.01195 C 278.1258,173.18465 270.1,170.58 261.8,170.42 c -4.33,-0.08 -8,1.37 -11,-5.82 -3.38,-8.07 -10.07,-19.35 -16.92,-12 -6.85,7.35 2,17.55 3.13,27 0.19,1.55 -0.08,3 -1.63,3.23 -3.79619,0.48553 -7.30604,2.27403 -9.93,5.06 -4.27,4.41 -8.18,-2.59 -15.09,3.8 -6.32,5.85 -13.27,-0.73 -25.52,-0.41 -20.65,0.56 -4.11,39.84 -4.11,39.84 z"
transform="translate(-113.88,-76.62)"
id="path207"
sodipodi:nodetypes="csscccccccccscccscccccc" />
<path
class="cls-4"
d="M200.74,254.41a27,27,0,0,0,1.31,3c2.16,4.33,1.11,2.86,3.11,6.36,1.22,2.13,4.06,6.21,2.11,6.86-1.68.56-4.06-1-4.75-3.08-.34-1-.59-2.08-.93-3.1-2.72-8.19-6.6-15.8-9.33-24-3-9-5.76-18.25-7.12-27.94-.46-3.57-3.28-10.18-.86-11,1.49-.49,2.14.53,2.48,1.55.74,2.23.09,2.45.5,3.66a1.31,1.31,0,0,0,1.7.78c1.68-.56,1.9-3.63,3.95-4.31,7.36-2.45,15.23,5.38,17.4,11.9,1.48,6-1.4,10.39-6.34,15.14a7.39,7.39,0,0,1-2.86,1.67c-2,.68-4.81.46-6.12.89s-1.61,1.36-1.15,2.76,1.21,2.7,1.67,4.1c.68,2,1.15,4.07,1.83,6.11a13.67,13.67,0,0,0,.71,1.83C198.94,249.94,200,252.18,200.74,254.41Zm-6.45-48.32c-6.52,2.17-4.88,13.31-3,19.08,1.3,3.91,4.19,5.44,8.09,4.14,5.59-1.86,9.23-8.65,7.37-14.24C205.11,211,199.22,204.45,194.29,206.09Z"
transform="translate(-113.88 -76.62)"
id="path209" />
<path
class="cls-4"
d="M216.34,197.41a.88.88,0,0,1,1.35.45c2.1,3.85,3.09,9.07,5.33,13.18,2.1,3.85,7.1,7,9.45,5.68,2.67-1.46,4.77-8.79,2.56-12.83-.64-1.17-1.64-2.24-2.5-3.8-1.32-2.41-2.15-6.11.13-7.35,1.37-.75,1.92-.2,2.31.52.07.13.08.29.15.43.25.46.59.78.84,1.23,1.1,2,1.14,4.88,1.54,6.86a21.85,21.85,0,0,0,2,6.08,14.16,14.16,0,0,0,4.19,4.74c.38.38,1,.73,1.24,1.18a1.24,1.24,0,0,1-.71,1.66c-.85.46-3.88-1.52-4.84-3.28-.21-.39-.3-.85-.55-1.31a.78.78,0,0,0-1.14-.23c-.65.36-.62,2.12-.83,2.82-.76,2-1.51,4.21-3.33,5.21-4,2.21-9.11-1.63-10.89-4.89-.43-.78-.79-1.6-1.25-2.45C219.43,207.58,215.43,197.91,216.34,197.41Z"
transform="translate(-113.88 -76.62)"
id="path211" />
<path
class="cls-4"
d="M251.1,184.25c.22.55.06,1.41,1.17,1,.62-.24.83-1.12,1.06-1.77a7.57,7.57,0,0,1,4.54-4.5c6.36-2.5,13.65,1.82,15.88,7.49,2.12,5.39,1,13.9-5.72,16.54a10.23,10.23,0,0,1-5.58.43,4,4,0,0,0-2-.18c-.9.35,0,2.33-1.08,2.74-1.94.76-3.25-.83-4.09-2.74-1.37-3.29-3.11-8.25-4.44-11.64s-2.43-7.19-3.84-10.79c-1.33-3.39-3-6.71-4.39-10.17-.6-1.52-1.13-3.07-1.7-4.52a17.75,17.75,0,0,0-2.25-4.31,1,1,0,0,1-.23-.39,1.46,1.46,0,0,1,1.06-1.77,1.54,1.54,0,0,1,2.14,1l.14.35a75.7,75.7,0,0,0,3.53,10.19c.35.9.8,1.84,1.15,2.74l-.07,0C247.73,177.34,249.77,180.86,251.1,184.25Zm8-3.06c-5.33,2.09-6.44,8.2-4.32,13.59a9.89,9.89,0,0,0,13,5.9c4.36-1.71,5.62-9.07,3.88-13.5C268.24,181.58,263.7,179.37,259.07,181.19Z"
transform="translate(-113.88 -76.62)"
id="path213" />
<path
class="cls-4"
d="M216.42,242.8c-.49-2.48,2.88-6.17,6.16-6.81,1.83-.36,4.94.47,5.28,2.22a1.91,1.91,0,0,1-1.12,2c-1.38.27-2.88-1.93-4.33-1.65s-3.39,2.41-3.13,3.72c.59,3,7.19,5.79,10.06,8.48a7,7,0,0,1,2.07,3.61,7.5,7.5,0,0,1-5.63,8.37c-5.76,1.13-9.07-3-11.63-6.81a2,2,0,0,1-.39-.83,1.4,1.4,0,0,1,1-1.71c.87-.17,1.43.7,1.86,1.38,1.5,3,4,6.41,8.28,5.57,2.7-.53,4.44-3.22,3.79-6.57C227.21,249.93,217.23,247,216.42,242.8Z"
transform="translate(-113.88 -76.62)"
id="path215" />
<path
class="cls-4"
d="M244.38,250.12c-6.18-5.9-6.33-14.67-2.63-18.54,2.46-2.58,9.1-4.36,13.13-.51,3.39,3.23,6.36,13.57,1.38,18.78a6.15,6.15,0,0,1-5.21,1.91,7.1,7.1,0,0,0-3.3.34c-.83.33-1,.34-1.85-.43-.27-.26-.59-.67-1.08-1.13Zm8.85-17.44a6.87,6.87,0,0,0-9.3.16c-2.62,2.74-1.09,12.22,1.38,14.58s7.16,2.94,9.68.3C258,244.55,257.58,236.83,253.23,232.67Z"
transform="translate(-113.88 -76.62)"
id="path217" />
<path
class="cls-4"
d="M269.24,237.75a63.21,63.21,0,0,0-2.83-6.39s-1-2.71-1.6-4.88c-.76-1.94-1.59-3.84-2.35-5.78-2.09-5.33-10.17-21.45-6.49-20.18,1.83.63,2.79.82,3.06,1.52a28.65,28.65,0,0,1,1,4.84c1.23,4.66,4.57,13.94,6.83,19.68.9,2.28,1.89,4.61,2.93,6.83,2,4.46,4.33,9.33,4.18,10.25C273.94,243.64,272.62,248.25,269.24,237.75Z"
transform="translate(-113.88 -76.62)"
id="path219" />
<path
class="cls-4"
d="M288.05,226.57c1-.37,1.51-1,1.29-2.58-.3-2.13-2.48-6.48-4.62-6.18-1.84.26-3.45,3.78-4.85,4-.59.08-1.23-.73-1.29-1.17-.26-1.84,4.38-4.66,5.92-4.88,5.22-.73,7.52,9.3,9.65,15.91.44,1.52,1,2.41,2.74,2.39.68.06,1.72,0,1.83.72A2.08,2.08,0,0,1,297,237c-1.25.17-2.61-1.51-3.64-1.37-2.06.29-3.5,3.94-6.3,4.33a4.31,4.31,0,0,1-4.94-3.66c0-.22,0-.45,0-.67C281.56,230.56,283.06,228.39,288.05,226.57ZM284.42,235c.21,1.47.62,2.84,2.54,2.57s5.26-4.26,5-6.32c-.33-2.36-2.1-2.71-3.91-2.76C285.44,228.81,284,231.64,284.42,235Z"
transform="translate(-113.88 -76.62)"
id="path221" />
<path
class="cls-4"
d="M306.07,208c2.38-.87,14.61-5.28,16.95-6.05.86-.23,1.94-.52,2.28.78.92,3.45-22.71,6-20.38,14.81,1.28,4.81,2.85,9.55,4,14.3.25.93.1,1.82-1,2.11-2.08.56-4.25-10.17-4.27-10.24C301.92,216.9,299.43,210.42,306.07,208Z"
transform="translate(-113.88 -76.62)"
id="path223" />
<ellipse
style="fill:#000000;fill-rule:evenodd;stroke-width:0.83809"
id="path429"
cx="172.17104"
cy="122.1034"
rx="4.5852861"
ry="4.9624691" />
<metadata
id="metadata3053">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>PubSolar logo</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View file

@ -1,5 +1,5 @@
// This file is auto-generated by the `update-kc-gen` command. Do not edit it manually.
// Hash: 52e835881710cf6fd39c1589bb9282feccdcf06c9547d41c919576489f251d2f
// Hash: a4bb051dacf961c9963f104bb9f9b28088f10d7beadd6cab6df0eb3f56500a51
/* eslint-disable */
@ -19,7 +19,7 @@ export const kcEnvNames: KcEnvName[] = [];
export const kcEnvDefaults: Record<KcEnvName, string> = {};
export type KcContext = import("./login/KcContext").KcContext;
type KcContext = import("./login/KcContext").KcContext;
declare global {
interface Window {

22
src/login/Background.tsx Normal file
View file

@ -0,0 +1,22 @@
const x1312 = (new Array(400)).fill("0x1312").join(" ");
export default function Background() {
return <div
id="background"
className="ps-background ps-main--background"
>
<div
id="x1312"
className="ps-background--1312"
>{x1312}</div>
{(new Array(Math.ceil(window.innerWidth / 100) * Math.ceil(window.innerHeight / 100)))
.fill(null)
.map((_, i) => <div key={i} className="ps-background--logo ps-logo">
<img
className="ps-logo--base"
src={`${import.meta.env.BASE_URL}pub.solar.svg`}
/>
</div>)
}
</div>;
}

View file

@ -1,9 +1,10 @@
import "../scss/index.scss";
import { Suspense, lazy } from "react";
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";
const UserProfileFormFields = lazy(
() => import("keycloakify/login/UserProfileFormFields")
);
@ -26,7 +27,6 @@ export default function KcPage(props: { kcContext: KcContext }) {
i18n={i18n}
classes={classes}
Template={Template}
doUseDefaultCss={true}
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
@ -37,4 +37,36 @@ export default function KcPage(props: { kcContext: KcContext }) {
);
}
const classes = {} satisfies { [key in ClassKey]?: string };
const classes = {
kcBodyClass: "ps-main ps-main_full",
kcContentClass: "ps-content",
kcLoginClass: "ps-main--page ps-page",
kcHeaderClass: "ps-page--header ps-header",
kcHeaderWrapperClass: "ps-header--wrapper",
kcFormCardClass: "ps-page--section ps-page--section_full",
kcFormClass: "ps-form",
kcFormHeaderClass: "ps-form--header",
kcFormGroupClass: "ps-form-group",
kcFormButtonsClass: "ps-form-group--buttons",
kcFormOptionsClass: "ps-form-group--options",
kcFormOptionsWrapperClass: "ps-form-group--options-wrapper",
kcButtonClass: "ps-button",
kcLabelClass: "ps-label",
kcInputClass: "ps-input",
kcInputGroup: "ps-input-group",
kcFormPasswordVisibilityButtonClass: "ps-input-group--password-visibility-button",
kcLocaleClass: "ps-locale",
kcLocaleMainClass: "ps-locale ps-header--i18n",
kcLocaleListClass: "ps-locale--list",
kcLocaleListItemClass: "ps-locale--list-item",
kcLocaleItemClass: "ps-locale--item",
kcLocaleWrapperClass: "ps-locale--wrapper",
kcLocaleDropDownClass: "ps-locale--dropdown"
} satisfies { [key in ClassKey]?: string };

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";
import Background from "./Background";
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", kcContext.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 (
<>
<Background />
<div className={kcClsx("kcLoginClass")}>
<div id="kc-header" className={kcClsx("kcHeaderClass")}>
<a href="https://pub.solar/" className="ps-homelink">pub.solar/</a>
{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>
)}
</div>
<div className={kcClsx("kcFormCardClass")}>
<header className={kcClsx("kcFormHeaderClass")}>
{(() => {
const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
<h1 id="kc-page-title" className="ps-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" className="ps-page--section-contents ps-container">
{/* 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
href="#"
id="try-another-way"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
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>
</>
);
}

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"
}
}}
/>
)
};

View file

@ -0,0 +1,95 @@
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={{
message: {
summary: "Server info message"
}
}}
/>
)
};
export const WithLinkBack: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "Server message"
},
actionUri: undefined
}}
/>
)
};
export const WithRequiredActions: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "Required actions: "
},
requiredActions: ["CONFIGURE_TOTP", "UPDATE_PROFILE", "VERIFY_EMAIL", "CUSTOM_ACTION"],
"x-keycloakify": {
messages: {
"requiredAction.CUSTOM_ACTION": "Custom action"
}
}
}}
/>
)
};
export const WithPageRedirect: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "You will be redirected shortly." },
pageRedirectUri: "https://example.com"
}}
/>
)
};
export const WithoutClientBaseUrl: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "No client base URL defined." },
client: { baseUrl: undefined }
}}
/>
)
};
export const WithMessageHeader: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Important Notice",
message: { summary: "This is an important message." }
}}
/>
)
};
export const WithAdvancedMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "Please take note of this <strong>important</strong> information." }
}}
/>
)
};

View file

@ -0,0 +1,360 @@
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 }
}}
/>
)
};

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: "credential2", userLabel: "Device 3" },
{ id: "credential2", userLabel: "Device 4" },
{ id: "credential2", userLabel: "Device 5" },
{ id: "credential2", 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"
}
}}
/>
)
};

View file

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-page-expired.ftl" });
const meta = {
title: "login/login-page-expired.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithErrorMessage:
* - Purpose: Tests behavior when an error message is displayed along with the page expiration message.
* - Scenario: Simulates a case where the session expired due to an error, and an error message is displayed alongside the expiration message.
* - Key Aspect: Ensures that error messages are displayed correctly in addition to the page expiration notice.
*/
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginRestartFlowUrl: "/mock-restart-flow",
loginAction: "/mock-continue-login"
},
message: {
type: "error",
summary: "An error occurred while processing your session."
}
}}
/>
)
};

View file

@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-password.ftl" });
const meta = {
title: "login/login-password.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithPasswordError:
* - Purpose: Tests the behavior when an error occurs in the password field (e.g., incorrect password).
* - Scenario: Simulates a scenario where an invalid password is entered, and an error message is displayed.
* - Key Aspect: Ensures that the password input field displays error messages correctly.
*/
export const WithPasswordError: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
resetPasswordAllowed: true
},
url: {
loginAction: "/mock-login",
loginResetCredentialsUrl: "/mock-reset-password"
},
messagesPerField: {
existsError: (field: string) => field === "password",
get: () => "Invalid password"
}
}}
/>
)
};
/**
* WithoutResetPasswordOption:
* - Purpose: Tests the behavior when the reset password option is disabled.
* - Scenario: Simulates a scenario where the `resetPasswordAllowed` is set to `false`, and the "Forgot Password" link is not rendered.
* - Key Aspect: Ensures that the component handles cases where resetting the password is not allowed.
*/
export const WithoutResetPasswordOption: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
resetPasswordAllowed: false
},
url: {
loginAction: "/mock-login",
loginResetCredentialsUrl: "/mock-reset-password"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};

View file

@ -0,0 +1,96 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-reset-otp.ftl" });
const meta = {
title: "login/login-reset-otp.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithoutOtpCredentials:
* - Purpose: Tests the behavior when no OTP credentials are available.
* - Scenario: The component renders without any OTP credentials, showing only the submit button.
* - Key Aspect: Ensures that the component handles the absence of OTP credentials correctly.
*/
export const WithoutOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login"
},
configuredOtpCredentials: {
userOtpCredentials: [],
selectedCredentialId: undefined
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};
/**
* WithOtpError:
* - Purpose: Tests the behavior when an error occurs with the OTP selection.
* - Scenario: Simulates a scenario where an error occurs (e.g., no OTP selected), and an error message is displayed.
* - Key Aspect: Ensures that error messages are displayed correctly for OTP-related errors.
*/
export const WithOtpError: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login"
},
configuredOtpCredentials: {
userOtpCredentials: [
{ id: "otp1", userLabel: "Device 1" },
{ id: "otp2", userLabel: "Device 2" }
],
selectedCredentialId: "otp1"
},
messagesPerField: {
existsError: (field: string) => field === "totp",
get: () => "Invalid OTP selection"
}
}}
/>
)
};
/**
* WithOnlyOneOtpCredential:
* - Purpose: Tests the behavior when there is only one OTP credential available.
* - Scenario: Simulates the case where the user has only one OTP credential, and it is pre-selected by default.
* - Key Aspect: Ensures that the component renders correctly with only one OTP credential pre-selected.
*/
export const WithOnlyOneOtpCredential: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login"
},
configuredOtpCredentials: {
userOtpCredentials: [{ id: "otp1", userLabel: "Device 1" }],
selectedCredentialId: "otp1"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};

View file

@ -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"
}
}}
/>
)
};

View file

@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-username.ftl" });
const meta = {
title: "login/login-username.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
}
}}
/>
)
};

View file

@ -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"
}
}}
/>
)
};

View file

@ -0,0 +1,278 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
import type { Attribute } from "keycloakify/login";
const { KcPageStory } = createKcPageStory({ pageId: "register.ftl" });
const meta = {
title: "login/register.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithEmailAlreadyExists: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
username: {
value: "johndoe"
},
email: {
value: "jhon.doe@gmail.com"
},
firstName: {
value: "John"
},
lastName: {
value: "Doe"
}
}
},
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[]) => [fieldName, ...otherFieldNames].includes("email"),
get: (fieldName: string) => (fieldName === "email" ? "Email already exists." : undefined)
}
}}
/>
)
};
export const WithRestrictedToMITStudents: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
email: {
validators: {
pattern: {
pattern: "^[^@]+@([^.]+\\.)*((mit\\.edu)|(berkeley\\.edu))$",
"error-message": "${profile.attributes.email.pattern.error}"
}
},
annotations: {
inputHelperTextBefore: "${profile.attributes.email.inputHelperTextBefore}"
}
}
}
},
"x-keycloakify": {
messages: {
"profile.attributes.email.inputHelperTextBefore": "Please use your MIT or Berkeley email.",
"profile.attributes.email.pattern.error":
"This is not an MIT (<strong>@mit.edu</strong>) nor a Berkeley (<strong>@berkeley.edu</strong>) email."
}
}
}}
/>
)
};
export const WithFavoritePet: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
favoritePet: {
name: "favorite-pet",
displayName: "${profile.attributes.favoritePet}",
validators: {
options: {
options: ["cat", "dog", "fish"]
}
},
annotations: {
inputOptionLabelsI18nPrefix: "profile.attributes.favoritePet.options"
},
required: false,
readOnly: false
} satisfies Attribute
}
},
"x-keycloakify": {
messages: {
"profile.attributes.favoritePet": "Favorite Pet",
"profile.attributes.favoritePet.options.cat": "Fluffy Cat",
"profile.attributes.favoritePet.options.dog": "Loyal Dog",
"profile.attributes.favoritePet.options.fish": "Peaceful Fish"
}
}
}}
/>
)
};
export const WithNewsletter: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
newsletter: {
name: "newsletter",
displayName: "Sign up to the newsletter",
validators: {
options: {
options: ["yes"]
}
},
annotations: {
inputOptionLabels: {
yes: "I want my email inbox filled with spam"
},
inputType: "multiselect-checkboxes"
},
required: false,
readOnly: false
} satisfies Attribute
}
}
}}
/>
)
};
export const WithEmailAsUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
registrationEmailAsUsername: true
},
profile: {
attributesByName: {
username: undefined
}
}
}}
/>
)
};
export const WithRecaptcha: Story = {
render: () => (
<KcPageStory
kcContext={{
scripts: ["https://www.google.com/recaptcha/api.js?hl=en"],
recaptchaRequired: true,
recaptchaSiteKey: "6LfQHvApAAAAAE73SYTd5vS0lB1Xr7zdiQ-6iBVa"
}}
/>
)
};
export const WithRecaptchaFrench: Story = {
render: () => (
<KcPageStory
kcContext={{
locale: {
currentLanguageTag: "fr"
},
scripts: ["https://www.google.com/recaptcha/api.js?hl=fr"],
recaptchaRequired: true,
recaptchaSiteKey: "6LfQHvApAAAAAE73SYTd5vS0lB1Xr7zdiQ-6iBVa"
}}
/>
)
};
export const WithPasswordMinLength8: Story = {
render: () => (
<KcPageStory
kcContext={{
passwordPolicies: {
length: 8
}
}}
/>
)
};
export const WithTermsAcceptance: Story = {
render: () => (
<KcPageStory
kcContext={{
termsAcceptanceRequired: true,
"x-keycloakify": {
messages: {
termsText: "<a href='https://example.com/terms'>Service Terms of Use</a>"
}
}
}}
/>
)
};
export const WithTermsNotAccepted: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
termsAcceptanceRequired: true,
messagesPerField: {
existsError: (fieldName: string) => fieldName === "termsAccepted",
get: (fieldName: string) => (fieldName === "termsAccepted" ? "You must accept the terms." : undefined)
}
}}
/>
)
};
export const WithFieldErrors: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
username: { value: "" },
email: { value: "invalid-email" }
}
},
messagesPerField: {
existsError: (fieldName: string) => ["username", "email"].includes(fieldName),
get: (fieldName: string) => {
if (fieldName === "username") return "Username is required.";
if (fieldName === "email") return "Invalid email format.";
}
}
}}
/>
)
};
export const WithReadOnlyFields: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
username: { value: "johndoe", readOnly: true },
email: { value: "jhon.doe@gmail.com", readOnly: false }
}
}
}}
/>
)
};
export const WithAutoGeneratedUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
username: { value: "autogenerated_username" }
}
}
}}
/>
)
};

View file

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

View file

@ -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
}}
/>
)
};

View file

@ -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"
}
}}
/>
)
};

27
src/scss/background.scss Normal file
View file

@ -0,0 +1,27 @@
.ps-background {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
speak: none;
&--logo {
width: 100px;
margin: 0.1rem;
}
&--1312 {
transform: rotate(34deg);
transform-origin: center center;
color: #eee;
position: fixed;
z-index: 1;
top: -50vh;
left: -50vw;
font-weight: 900;
font-size: 7rem;
line-height: 6rem;
width: 200vw;
height: 200vh;
}
}

32
src/scss/button.scss Normal file
View file

@ -0,0 +1,32 @@
.ps-button {
font-size: 1rem;
padding: 0.5em 1em;
line-height: 1.2em;
border: 0.125em solid var(--foreground);
border-radius: 1.5em;
background-color: var(--background-darker-2);
cursor: pointer;
&:hover,
&:focus {
border-color: var(--accent);
}
&_primary {
border: 0.25em solid var(--foreground);
background-color: var(--background);
color: var(--foreground);
font-weight: bold;
&:focus,
&:hover {
background-color: var(--foreground);
color: var(--background);
}
}
&_small {
font-size: 0.8rem;
padding: 0.25em 0.7em;
}
}

19
src/scss/card.scss Normal file
View file

@ -0,0 +1,19 @@
.ps-card {
background-color: var(--background);
display: flex;
flex-direction: column;
border: 1rem solid var(--foreground);
&--header {
padding: 2rem;
}
&--title {
margin: 0;
padding: 0;
}
&--body {
padding: 0rem 2rem;
}
}

10
src/scss/container.scss Normal file
View file

@ -0,0 +1,10 @@
.ps-container {
display: flex;
flex-direction: column;
align-items: stretch;
> * {
margin: 0;
margin-bottom: 1.25rem;
}
}

19
src/scss/footer.scss Normal file
View file

@ -0,0 +1,19 @@
.ps-footer {
display: flex;
margin-top: auto;
z-index: 1;
&--link {
&:hover {
text-shadow: 0.2vw 0px 0px var(--accent);
}
@media screen and (min-width: 700px) {
font-size: 4rem;
}
@media screen and (min-width: 1000px) {
font-size: 2rem;
}
}
}

21
src/scss/form-group.scss Normal file
View file

@ -0,0 +1,21 @@
.ps-form-group {
display: flex;
flex-direction: column;
&--label {
margin-bottom: 0.5rem;
display: flex;
font-weight: bold;
}
.ps-button {
align-self: flex-start;
}
&--error {
margin-top: 0.25rem;
color: var(--accent);
font-weight: bold;
// font-family: monospace;
}
}

55
src/scss/header.scss Normal file
View file

@ -0,0 +1,55 @@
.ps-header {
display: flex;
justify-content: space-between;
padding: 0;
margin: 0;
overflow-x: auto;
z-index: 100;
align-items: flex-start;
&--title {
font-size: 1.5rem;
padding: 0 1rem;
margin: 0;
border-bottom: 0.5rem solid var(--foreground);
background-color: var(--background);
border-right: 0.5rem solid var(--foreground);
pointer-events: all;
}
&--i18n {
margin-left: auto;
}
&--nav {
display: flex;
border-bottom: 0.5rem solid var(--foreground);
border-left: 0.5rem solid var(--foreground);
background-color: var(--background);
padding-left: 1rem;
pointer-events: all;
}
&--nav-list {
display: flex;
justify-content: flex-end;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
&--nav-item {
margin: 0;
margin-right: 1rem;
&-action {
color: var(--foreground);
text-decoration: none;
&:hover {
color: var(--accent);
}
}
}
}

25
src/scss/homelink.scss Normal file
View file

@ -0,0 +1,25 @@
.ps-homelink {
pointer-events: all;
color: var(--foreground);
background: white;
text-decoration: none;
text-align: center;
font-weight: 900;
font-size: 24px;
padding: 8px;
line-height: 1em;
text-shadow: 0.15vw 0px 0px white;
transition: text-shadow 0.1s ease;
border: 12px solid black;
border-top: 0;
border-left: 0;
&:hover {
text-shadow: 0.3vw 0px 0px var(--accent);
}
@media screen and (min-width: 1200px) {
font-size: 32px;
padding: 12px;
}
}

18
src/scss/i18n-links.scss Normal file
View file

@ -0,0 +1,18 @@
.ps-i18n-links {
display: flex;
margin: 0;
pointer-events: all;
color: var(--foreground);
background: white;
text-decoration: none;
text-align: center;
font-weight: 900;
font-size: 24px;
padding: 8px;
line-height: 1em;
text-shadow: 0.15vw 0px 0px var(--background);
transition: text-shadow 0.1s ease;
border: 12px solid var(--foreground);
border-top: 0;
border-right: 0;
}

55
src/scss/index.scss Normal file
View file

@ -0,0 +1,55 @@
* {
box-sizing: border-box;
}
html {
--accent: #ed1c24;
--foreground: #000;
--foreground-lighter-1: rgba(0, 0, 0, 0.7);
--foreground-lighter-2: rgba(0, 0, 0, 0.3);
--background: #fff;
--background-darker-1: #f5f5f5;
--background-darker-2: #eeeeee;
font-family: "Open Sans", Arial, sans-serif;
font-weight: 800;
background: var(--background);
color: var(--foreground);
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
box-sizing: border-box;
width: 100vw;
height: 100vh;
margin: 0;
font-size: 16px;
@media screen and (min-width: 1200px) {
font-size: 20px;
}
}
*:focus-visible {
outline: 0.2rem solid var(--accent);
}
@import "./container";
@import "./link";
@import "./locale";
@import "./card";
@import "./button";
@import "./input";
@import "./table";
@import "./form-group";
@import "./homelink";
@import "./i18n-links";
@import "./main";
@import "./header";
@import "./page";
@import "./login-flow-pre";
@import "./section-nav";
@import "./logo";
@import "./background";
@import "./footer";

23
src/scss/input.scss Normal file
View file

@ -0,0 +1,23 @@
.ps-input {
padding: 0.5rem 0.5rem;
border: 2px solid var(--foreground-lighter-1);
font-size: 1.2rem;
&:hover,
&:focus {
border-color: var(--accent);
}
&:focus {
color: var(--background);
background-color: var(--foreground);
}
&:disabled {
background-color: var(--background-darker-2);
&:hover {
border-color: var(--foreground-lighter-1);
}
}
}

11
src/scss/link.scss Normal file
View file

@ -0,0 +1,11 @@
.ps-link {
cursor: pointer;
color: var(--accent);
border-bottom: 1px solid transparent;
transition: border-bottom 0.1s ease;
text-decoration: none;
&:hover {
border-bottom: 4px solid var(--accent);
}
}

62
src/scss/locale.scss Normal file
View file

@ -0,0 +1,62 @@
.ps-locale {
pointer-events: all;
&--wrapper {
position: relative;
}
&--dropdown {
display: flex;
flex-direction: column;
justify-content: flex-end;
button {
margin: 0;
margin-left: auto;
color: var(--foreground);
background: white;
text-decoration: none;
text-align: center;
font-weight: 900;
font-size: 24px;
padding: 8px;
line-height: 1em;
text-shadow: 0.15vw 0px 0px var(--background);
transition: text-shadow 0.1s ease;
border: 12px solid var(--foreground);
border-top: 0;
border-right: 0;
&:hover {
text-shadow: 0.2vw 0px 0px var(--accent);
}
@media screen and (min-width: 1200px) {
font-size: 32px;
padding: 12px;
}
}
}
&--list {
display: none;
border: 8px solid var(--foreground);
border-right: 0;
padding: 0;
margin: 0;
margin-top: -8px;
background: white;
list-style: none;
}
&--item {
color: var(--foreground);
text-decoration: none;
padding: 2px 8px;
display: flex;
&:hover {
text-shadow: 0.1vw 0px 0px var(--accent);
}
}
}

View file

@ -0,0 +1,16 @@
.ps-login-flow-pre {
margin: 2rem;
display: flex;
border-bottom: 0.25rem solid var(--foreground);
padding-bottom: 0.25rem;
&--selected {
font-weight: bold;
margin-right: 1rem;
}
&--cancel {
font-weight: normal;
}
}

57
src/scss/logo.scss Normal file
View file

@ -0,0 +1,57 @@
.ps-logo {
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
width: 100px;
&--base {
animation-name: rotate;
animation-duration: 0.3s;
animation-timing-function: linear;
animation-play-state: paused;
animation-iteration-count: infinite;
&:hover {
animation-play-state: running;
}
}
&:nth-child(2n) &--base {
animation-delay: -0.1s;
}
&:nth-child(3n) &--base {
animation-delay: -0.3s;
}
&:nth-child(5n) &--base {
animation-delay: -0.5s;
}
&:nth-child(7n) &--base {
animation-delay: -7s;
}
&:nth-child(11n) &--base {
animation-delay: -0.9s;
}
&:nth-child(13n) &--base {
animation-delay: -1s;
}
&--base::before {
position: absolute;
display: block;
content: "";
width: 100%;
height: 100%;
border-radius: 50%;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

38
src/scss/main.scss Normal file
View file

@ -0,0 +1,38 @@
.ps-main {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: space-between;
height: 100vh;
width: 100vw;
margin: 0;
padding-top: 10vw;
padding-bottom: 2vw;
overflow: auto;
position: relative;
&--background {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
overflow: hidden;
z-index: 0;
}
&--page {
z-index: 1;
}
&_full {
padding: 0;
}
&_full &--page {
@media screen and (min-width: 1200px) {
flex-direction: column;
flex-wrap: nowrap;
}
}
}

3
src/scss/nav.scss Normal file
View file

@ -0,0 +1,3 @@
.ps-nav {
display: flex;
}

152
src/scss/page.scss Normal file
View file

@ -0,0 +1,152 @@
.ps-page {
display: flex;
width: 100vw;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 0;
pointer-events: none;
padding-top: 4rem;
position: relative;
@media screen and (min-width: 1200px) {
padding: 1vw;
padding-top: 4rem;
flex-direction: row;
align-items: flex-start;
}
&_home {
flex-direction: row;
flex-wrap: wrap;
}
&--header {
width: 100%;
top: 0;
left: 0;
right: 0;
height: auto;
position: fixed;
}
&--title {
font-size: 2rem;
border-bottom: 0.5rem solid var(--foreground);
padding-bottom: 0.5rem;
margin: 2rem;
}
&--section {
border: 12px solid black;
margin-top: 2rem;
margin-bottom: 2rem;
max-width: 700px;
flex-basis: 100%;
flex-shrink: 1;
pointer-events: all;
color: var(--foreground);
background: var(--background);
overflow-wrap: break-word;
hyphens: auto;
pointer-events: all;
@media screen and (min-width: 1200px) {
margin: 1vw;
}
&_home {
padding: 5vw;
}
&_full {
max-width: unset;
width: calc(100vw - 1.6rem);
margin: 0.8rem;
@media screen and (min-width: 1200px) {
width: 96vw;
margin: 1vw;
}
}
a {
color: var(--accent);
border-bottom: 1px solid transparent;
transition: border-bottom 0.1s ease;
text-decoration: none;
&:hover {
border-bottom: 4px solid var(--accent);
}
}
img {
width: 230px;
margin-top: 1rem;
}
}
&--section-link {
position: sticky;
top: 0;
background-color: var(--background);
padding: 1rem;
display: flex;
justify-content: flex-end;
align-items: center;
text-align: right;
padding-left: 132px;
@media screen and (min-width: 1200px) {
display: none;
border-bottom: 0;
}
&-icon {
margin-left: 8px;
}
}
&--section-contents {
margin: 2rem;
margin-bottom: 0;
font-weight: 500;
line-height: 1.4;
&:last-child {
margin-bottom: 5vw;
}
pre,
code {
background-color: var(--background-darker-2);
border-radius: 4px;
padding: 4px;
}
pre {
border: 1px solid var(--foreground-lighter-2);
}
> * {
margin-bottom: 0;
margin-top: 0.5rem;
}
> .ps-table {
margin-top: 1rem;
+ * {
margin-top: 1rem;
}
}
> h2,
h3,
h4 {
margin-top: 1.5rem;
line-height: 1.5;
}
}
}

38
src/scss/section-nav.scss Normal file
View file

@ -0,0 +1,38 @@
.ps-section-nav {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
position: sticky;
top: 0;
left: 0;
width: 100%;
background-color: var(--background);
border-bottom: 2px solid var(--foreground);
overflow-x: auto;
&--list {
list-style: none;
display: flex;
position: sticky;
margin: 0 -0.5rem;
}
&--link {
display: flex;
a {
padding: 1rem 0.5rem;
}
&_active {
a {
color: var(--foreground);
border-bottom: 4px solid var(--foreground);
&:hover {
border-color: var(--foreground);
}
}
}
}
}

37
src/scss/table.scss Normal file
View file

@ -0,0 +1,37 @@
.ps-table {
overflow: auto;
border-collapse: collapse;
margin-left: -0.25rem;
width: calc(100% + 0.25rem);
td,
th {
padding: 0.1rem 0.25rem;
}
tr {
border: 0;
border-left: 0.25rem solid transparent;
&:nth-child(2n) {
background-color: var(--background-darker-1);
border-left: 0.25rem solid var(--background-darker-1);
}
&:hover {
background-color: var(--background-darker-2);
border-color: var(--accent);
}
}
thead tr {
border-bottom: 4px solid var(--foreground-lighter-1);
font-weight: bold;
text-align: center;
}
thead td,
th {
padding: 0.1rem 0.5rem;
}
}

322
src/scss/typography.scss Normal file
View file

@ -0,0 +1,322 @@
/* To regenerate this file, run $ google-font-downloader 'https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,800;1,400;1,800&display=swap' */
/* cyrillic-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
// }
// /* cyrillic */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
// }
// /* greek-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+1F00-1FFF;
// }
// /* greek */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0370-03FF;
// }
// /* hebrew */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
// }
// /* vietnamese */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
// }
// /* latin-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
// }
// /* latin */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6FxZCJgg.woff2) format('woff2');
// unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
// }
// /* cyrillic-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtE6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
// }
// /* cyrillic */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWvU6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
// }
// /* greek-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtU6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+1F00-1FFF;
// }
// /* greek */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuk6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0370-03FF;
// }
// /* hebrew */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWu06FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
// }
// /* vietnamese */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWtk6FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
// }
// /* latin-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWt06FxZCJgvAQ.woff2) format('woff2');
// unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
// }
// /* latin */
// @font-face {
// font-family: 'Open Sans';
// font-style: italic;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memtYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWqWuU6FxZCJgg.woff2) format('woff2');
// unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
// }
// /* cyrillic-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
// }
// /* cyrillic */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
// }
// /* greek-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+1F00-1FFF;
// }
// /* greek */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0370-03FF;
// }
// /* hebrew */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
// }
// /* vietnamese */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
// }
// /* latin-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
// }
// /* latin */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 400;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-mu0SC55I.woff2) format('woff2');
// unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
// }
// /* cyrillic-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSKmu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
// }
// /* cyrillic */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSumu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
// }
// /* greek-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSOmu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+1F00-1FFF;
// }
// /* greek */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSymu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0370-03FF;
// }
// /* hebrew */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS2mu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;
// }
// /* vietnamese */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSCmu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
// }
// /* latin-ext */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTSGmu0SC55K5gw.woff2) format('woff2');
// unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
// }
// /* latin */
// @font-face {
// font-family: 'Open Sans';
// font-style: normal;
// font-weight: 800;
// font-stretch: 100%;
// font-display: swap;
// src: url(fonts/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-mu0SC55I.woff2) format('woff2');
// unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
// }

2090
yarn.lock

File diff suppressed because it is too large Load diff