diff --git a/.gitignore b/.gitignore
index baba83b..9d42943 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,5 +12,9 @@
# Logs
*.log
-# Keycloak data
-keycloak/
+# Keycloak runtime data (but keep realm-export.json)
+keycloak/*
+!keycloak/realm-export.json
+
+# Node modules
+node_modules/
diff --git a/Cargo.lock b/Cargo.lock
index d59bbb3..ef8a182 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -739,6 +739,7 @@ version = "0.1.0"
dependencies = [
"async-stripe",
"axum",
+ "base64 0.22.1",
"chrono",
"dioxus",
"dioxus-cli-config",
@@ -755,6 +756,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
+ "sha2",
"thiserror 2.0.18",
"time",
"tokio",
diff --git a/Cargo.toml b/Cargo.toml
index 46b29eb..1dae3cb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -73,6 +73,8 @@ dioxus-free-icons = { version = "0.10", features = [
"bootstrap",
"font-awesome-solid",
] }
+sha2 = { version = "0.10.9", default-features = false, optional = true }
+base64 = { version = "0.22.1", default-features = false, optional = true }
[features]
# default = ["web"]
@@ -87,6 +89,8 @@ server = [
"dep:time",
"dep:rand",
"dep:url",
+ "dep:sha2",
+ "dep:base64",
]
[[bin]]
diff --git a/assets/logo.svg b/assets/logo.svg
new file mode 100644
index 0000000..ac16408
--- /dev/null
+++ b/assets/logo.svg
@@ -0,0 +1,25 @@
+
diff --git a/assets/tailwind.css b/assets/tailwind.css
index b18b269..e626241 100644
--- a/assets/tailwind.css
+++ b/assets/tailwind.css
@@ -1,15 +1,14 @@
-/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */
+/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
- --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
- 'Noto Color Emoji';
- --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
- monospace;
+ --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
+ "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
+ "Courier New", monospace;
+ --color-black: #000;
--spacing: 0.25rem;
- --default-transition-duration: 150ms;
- --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
@@ -25,7 +24,7 @@
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
- font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
+ font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
@@ -52,7 +51,7 @@
font-weight: bolder;
}
code, kbd, samp, pre {
- font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
+ font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
@@ -146,23 +145,577 @@
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
+ ::-webkit-calendar-picker-indicator {
+ line-height: 1;
+ }
:-moz-ui-invalid {
box-shadow: none;
}
- button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
+ button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
- [hidden]:where(:not([hidden='until-found'])) {
+ [hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
+ .diff {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ display: grid;
+ width: 100%;
+ overflow: hidden;
+ webkit-user-select: none;
+ user-select: none;
+ grid-template-rows: 1fr 1.8rem 1fr;
+ direction: ltr;
+ container-type: inline-size;
+ grid-template-columns: auto 1fr;
+ &:focus-visible, &:has(.diff-item-1:focus-visible) {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ outline-color: var(--color-base-content);
+ }
+ &:focus-visible {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ outline-color: var(--color-base-content);
+ .diff-resizer {
+ min-width: 95cqi;
+ max-width: 95cqi;
+ }
+ }
+ &:has(.diff-item-1:focus-visible) {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ .diff-resizer {
+ min-width: 5cqi;
+ max-width: 5cqi;
+ }
+ }
+ @supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
+ &:focus {
+ .diff-resizer {
+ min-width: 5cqi;
+ max-width: 5cqi;
+ }
+ }
+ &:has(.diff-item-1:focus) {
+ .diff-resizer {
+ min-width: 95cqi;
+ max-width: 95cqi;
+ }
+ }
+ }
+ }
+ }
+ .loading {
+ @layer daisyui.l1.l2.l3 {
+ pointer-events: none;
+ display: inline-block;
+ aspect-ratio: 1 / 1;
+ background-color: currentcolor;
+ vertical-align: middle;
+ width: calc(var(--size-selector, 0.25rem) * 6);
+ mask-size: 100%;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
+ }
+ }
.visible {
visibility: visible;
}
+ .toggle {
+ @layer daisyui.l1.l2.l3 {
+ border: var(--border) solid currentColor;
+ color: var(--input-color);
+ position: relative;
+ display: inline-grid;
+ flex-shrink: 0;
+ cursor: pointer;
+ appearance: none;
+ place-content: center;
+ vertical-align: middle;
+ webkit-user-select: none;
+ user-select: none;
+ grid-template-columns: 0fr 1fr 1fr;
+ --radius-selector-max: calc(
+ var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
+ );
+ border-radius: calc( var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)) );
+ padding: var(--toggle-p);
+ box-shadow: 0 1px currentColor inset;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;
+ }
+ transition: color 0.3s, grid-template-columns 0.2s;
+ --input-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ --input-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
+ }
+ --toggle-p: calc(var(--size) * 0.125);
+ --size: calc(var(--size-selector, 0.25rem) * 6);
+ width: calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);
+ height: var(--size);
+ > * {
+ z-index: 1;
+ grid-column: span 1 / span 1;
+ grid-column-start: 2;
+ grid-row-start: 1;
+ height: 100%;
+ cursor: pointer;
+ appearance: none;
+ background-color: transparent;
+ padding: calc(0.25rem * 0.5);
+ transition: opacity 0.2s, rotate 0.4s;
+ border: none;
+ &:focus {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ &:nth-child(2) {
+ color: var(--color-base-100);
+ rotate: 0deg;
+ }
+ &:nth-child(3) {
+ color: var(--color-base-100);
+ opacity: 0%;
+ rotate: -15deg;
+ }
+ }
+ &:has(:checked) {
+ > :nth-child(2) {
+ opacity: 0%;
+ rotate: 15deg;
+ }
+ > :nth-child(3) {
+ opacity: 100%;
+ rotate: 0deg;
+ }
+ }
+ &:before {
+ position: relative;
+ inset-inline-start: calc(0.25rem * 0);
+ grid-column-start: 2;
+ grid-row-start: 1;
+ aspect-ratio: 1 / 1;
+ height: 100%;
+ width: 100%;
+ border-radius: var(--radius-selector);
+ background-color: currentcolor;
+ translate: 0;
+ --tw-content: "";
+ content: var(--tw-content);
+ transition: background-color 0.1s, translate 0.2s, inset-inline-start 0.2s;
+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);
+ }
+ background-size: auto, calc(var(--noise) * 100%);
+ background-image: none, var(--fx-noise);
+ }
+ @media (forced-colors: active) {
+ &:before {
+ outline-style: var(--tw-outline-style);
+ outline-width: 1px;
+ outline-offset: calc(1px * -1);
+ }
+ }
+ @media print {
+ &:before {
+ outline: 0.25rem solid;
+ outline-offset: -1rem;
+ }
+ }
+ &:focus-visible, &:has(:focus-visible) {
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+ }
+ &:checked, &[aria-checked="true"], &:has(> input:checked) {
+ grid-template-columns: 1fr 1fr 0fr;
+ background-color: var(--color-base-100);
+ --input-color: var(--color-base-content);
+ &:before {
+ background-color: currentcolor;
+ }
+ @starting-style {
+ &:before {
+ opacity: 0;
+ }
+ }
+ }
+ &:indeterminate {
+ grid-template-columns: 0.5fr 1fr 0.5fr;
+ }
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 30%;
+ &:before {
+ background-color: transparent;
+ border: var(--border) solid currentColor;
+ }
+ }
+ }
+ }
+ .input {
+ @layer daisyui.l1.l2.l3 {
+ cursor: text;
+ border: var(--border) solid #0000;
+ position: relative;
+ display: inline-flex;
+ flex-shrink: 1;
+ appearance: none;
+ align-items: center;
+ gap: calc(0.25rem * 2);
+ background-color: var(--color-base-100);
+ padding-inline: calc(0.25rem * 3);
+ vertical-align: middle;
+ white-space: nowrap;
+ width: clamp(3rem, 20rem, 100%);
+ height: var(--size);
+ font-size: max(var(--font-size, 0.875rem), 0.875rem);
+ touch-action: manipulation;
+ border-start-start-radius: var(--join-ss, var(--radius-field));
+ border-start-end-radius: var(--join-se, var(--radius-field));
+ border-end-start-radius: var(--join-es, var(--radius-field));
+ border-end-end-radius: var(--join-ee, var(--radius-field));
+ border-color: var(--input-color);
+ box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ }
+ --size: calc(var(--size-field, 0.25rem) * 10);
+ --input-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
+ }
+ &:where(input) {
+ display: inline-flex;
+ }
+ :where(input) {
+ display: inline-flex;
+ height: 100%;
+ width: 100%;
+ appearance: none;
+ background-color: transparent;
+ border: none;
+ &:focus, &:focus-within {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ }
+ :where(input[type="url"]), :where(input[type="email"]) {
+ direction: ltr;
+ }
+ :where(input[type="date"]) {
+ display: inline-flex;
+ }
+ &:focus, &:focus-within {
+ --input-color: var(--color-base-content);
+ box-shadow: 0 1px var(--input-color);
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
+ }
+ outline: 2px solid var(--input-color);
+ outline-offset: 2px;
+ isolation: isolate;
+ }
+ @media (pointer: coarse) {
+ @supports (-webkit-touch-callout: none) {
+ &:focus, &:focus-within {
+ --font-size: 1rem;
+ }
+ }
+ }
+ &:has(> input[disabled]), &:is(:disabled, [disabled]), fieldset:disabled & {
+ cursor: not-allowed;
+ border-color: var(--color-base-200);
+ background-color: var(--color-base-200);
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
+ }
+ &::placeholder {
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ }
+ }
+ box-shadow: none;
+ }
+ &:has(> input[disabled]) > input[disabled] {
+ cursor: not-allowed;
+ }
+ &::-webkit-date-and-time-value {
+ text-align: inherit;
+ }
+ &[type="number"] {
+ &::-webkit-inner-spin-button {
+ margin-block: calc(0.25rem * -3);
+ margin-inline-end: calc(0.25rem * -3);
+ }
+ }
+ &::-webkit-calendar-picker-indicator {
+ position: absolute;
+ inset-inline-end: 0.75em;
+ }
+ &:has(> input[type="date"]) {
+ :where(input[type="date"]) {
+ display: inline-flex;
+ webkit-appearance: none;
+ appearance: none;
+ }
+ input[type="date"]::-webkit-calendar-picker-indicator {
+ position: absolute;
+ inset-inline-end: 0.75em;
+ width: 1em;
+ height: 1em;
+ cursor: pointer;
+ }
+ }
+ }
+ }
+ .steps {
+ @layer daisyui.l1.l2.l3 {
+ display: inline-grid;
+ grid-auto-flow: column;
+ overflow: hidden;
+ overflow-x: auto;
+ counter-reset: step;
+ grid-auto-columns: 1fr;
+ .step {
+ display: grid;
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ grid-template-columns: auto;
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+ grid-template-rows: 40px 1fr;
+ place-items: center;
+ text-align: center;
+ min-width: 4rem;
+ --step-bg: var(--color-base-300);
+ --step-fg: var(--color-base-content);
+ &:before {
+ top: calc(0.25rem * 0);
+ grid-column-start: 1;
+ grid-row-start: 1;
+ height: calc(0.25rem * 2);
+ width: 100%;
+ border: 1px solid;
+ color: var(--step-bg);
+ background-color: var(--step-bg);
+ content: "";
+ margin-inline-start: -100%;
+ }
+ > .step-icon, &:not(:has(.step-icon)):after {
+ --tw-content: counter(step);
+ content: var(--tw-content);
+ counter-increment: step;
+ z-index: 1;
+ color: var(--step-fg);
+ background-color: var(--step-bg);
+ border: 1px solid var(--step-bg);
+ position: relative;
+ grid-column-start: 1;
+ grid-row-start: 1;
+ display: grid;
+ height: calc(0.25rem * 8);
+ width: calc(0.25rem * 8);
+ place-items: center;
+ place-self: center;
+ border-radius: calc(infinity * 1px);
+ }
+ &:first-child:before {
+ --tw-content: none;
+ content: var(--tw-content);
+ }
+ &[data-content]:after {
+ --tw-content: attr(data-content);
+ content: var(--tw-content);
+ }
+ }
+ }
+ @layer daisyui.l1.l2 {
+ .step-neutral {
+ + .step-neutral:before, &:after, > .step-icon {
+ --step-bg: var(--color-neutral);
+ --step-fg: var(--color-neutral-content);
+ }
+ }
+ .step-primary {
+ + .step-primary:before, &:after, > .step-icon {
+ --step-bg: var(--color-primary);
+ --step-fg: var(--color-primary-content);
+ }
+ }
+ .step-secondary {
+ + .step-secondary:before, &:after, > .step-icon {
+ --step-bg: var(--color-secondary);
+ --step-fg: var(--color-secondary-content);
+ }
+ }
+ .step-accent {
+ + .step-accent:before, &:after, > .step-icon {
+ --step-bg: var(--color-accent);
+ --step-fg: var(--color-accent-content);
+ }
+ }
+ .step-info {
+ + .step-info:before, &:after, > .step-icon {
+ --step-bg: var(--color-info);
+ --step-fg: var(--color-info-content);
+ }
+ }
+ .step-success {
+ + .step-success:before, &:after, > .step-icon {
+ --step-bg: var(--color-success);
+ --step-fg: var(--color-success-content);
+ }
+ }
+ .step-warning {
+ + .step-warning:before, &:after, > .step-icon {
+ --step-bg: var(--color-warning);
+ --step-fg: var(--color-warning-content);
+ }
+ }
+ .step-error {
+ + .step-error:before, &:after, > .step-icon {
+ --step-bg: var(--color-error);
+ --step-fg: var(--color-error-content);
+ }
+ }
+ }
+ }
+ .avatar {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ display: inline-flex;
+ vertical-align: middle;
+ & > div {
+ display: block;
+ aspect-ratio: 1 / 1;
+ overflow: hidden;
+ }
+ img {
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+ }
+ }
+ }
+ .card {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ border-radius: var(--radius-box);
+ outline-width: 2px;
+ transition: outline 0.2s ease-in-out;
+ outline: 0 solid #0000;
+ outline-offset: 2px;
+ &:focus {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ &:focus-visible {
+ outline-color: currentColor;
+ }
+ :where(figure:first-child) {
+ overflow: hidden;
+ border-start-start-radius: inherit;
+ border-start-end-radius: inherit;
+ border-end-start-radius: unset;
+ border-end-end-radius: unset;
+ }
+ :where(figure:last-child) {
+ overflow: hidden;
+ border-start-start-radius: unset;
+ border-start-end-radius: unset;
+ border-end-start-radius: inherit;
+ border-end-end-radius: inherit;
+ }
+ figure {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ &:has(> input:is(input[type="checkbox"], input[type="radio"])) {
+ cursor: pointer;
+ user-select: none;
+ }
+ &:has(> :checked) {
+ outline: 2px solid currentColor;
+ }
+ }
+ }
+ .progress {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ height: calc(0.25rem * 2);
+ width: 100%;
+ appearance: none;
+ overflow: hidden;
+ border-radius: var(--radius-box);
+ background-color: currentcolor;
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, currentcolor 20%, transparent);
+ }
+ color: var(--color-base-content);
+ &:indeterminate {
+ background-image: repeating-linear-gradient( 90deg, currentColor -1%, currentColor 10%, #0000 10%, #0000 90% );
+ background-size: 200%;
+ background-position-x: 15%;
+ @media (prefers-reduced-motion: no-preference) {
+ animation: progress 5s ease-in-out infinite;
+ }
+ @supports (-moz-appearance: none) {
+ &::-moz-progress-bar {
+ background-color: transparent;
+ @media (prefers-reduced-motion: no-preference) {
+ animation: progress 5s ease-in-out infinite;
+ background-image: repeating-linear-gradient( 90deg, currentColor -1%, currentColor 10%, #0000 10%, #0000 90% );
+ background-size: 200%;
+ background-position-x: 15%;
+ }
+ }
+ }
+ }
+ @supports (-moz-appearance: none) {
+ &::-moz-progress-bar {
+ border-radius: var(--radius-box);
+ background-color: currentcolor;
+ }
+ }
+ @supports (-webkit-appearance: none) {
+ &::-webkit-progress-bar {
+ border-radius: var(--radius-box);
+ background-color: transparent;
+ }
+ &::-webkit-progress-value {
+ border-radius: var(--radius-box);
+ background-color: currentColor;
+ }
+ }
+ }
+ }
.fixed {
position: fixed;
}
@@ -172,6 +725,87 @@
.static {
position: static;
}
+ .stack {
+ @layer daisyui.l1.l2.l3 {
+ display: inline-grid;
+ grid-template-columns: 3px 4px 1fr 4px 3px;
+ grid-template-rows: 3px 4px 1fr 4px 3px;
+ & > * {
+ height: 100%;
+ width: 100%;
+ &:nth-child(n + 2) {
+ width: 100%;
+ opacity: 70%;
+ }
+ &:nth-child(2) {
+ z-index: 2;
+ opacity: 90%;
+ }
+ &:nth-child(1) {
+ z-index: 3;
+ width: 100%;
+ }
+ }
+ }
+ @layer daisyui.l1.l2 {
+ &, &.stack-bottom {
+ > * {
+ grid-column: 3 / 4;
+ grid-row: 3 / 6;
+ &:nth-child(2) {
+ grid-column: 2 / 5;
+ grid-row: 2 / 5;
+ }
+ &:nth-child(1) {
+ grid-column: 1 / 6;
+ grid-row: 1 / 4;
+ }
+ }
+ }
+ &.stack-top {
+ > * {
+ grid-column: 3 / 4;
+ grid-row: 1 / 4;
+ &:nth-child(2) {
+ grid-column: 2 / 5;
+ grid-row: 2 / 5;
+ }
+ &:nth-child(1) {
+ grid-column: 1 / 6;
+ grid-row: 3 / 6;
+ }
+ }
+ }
+ &.stack-start {
+ > * {
+ grid-column: 1 / 4;
+ grid-row: 3 / 4;
+ &:nth-child(2) {
+ grid-column: 2 / 5;
+ grid-row: 2 / 5;
+ }
+ &:nth-child(1) {
+ grid-column: 3 / 6;
+ grid-row: 1 / 6;
+ }
+ }
+ }
+ &.stack-end {
+ > * {
+ grid-column: 3 / 6;
+ grid-row: 3 / 4;
+ &:nth-child(2) {
+ grid-column: 2 / 5;
+ grid-row: 2 / 5;
+ }
+ &:nth-child(1) {
+ grid-column: 1 / 4;
+ grid-row: 1 / 6;
+ }
+ }
+ }
+ }
+ }
.container {
width: 100%;
@media (width >= 40rem) {
@@ -190,8 +824,103 @@
max-width: 96rem;
}
}
- .flex {
- display: flex;
+ .label {
+ @layer daisyui.l1.l2.l3 {
+ display: inline-flex;
+ align-items: center;
+ gap: calc(0.25rem * 1.5);
+ white-space: nowrap;
+ color: currentcolor;
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, currentcolor 60%, transparent);
+ }
+ &:has(input) {
+ cursor: pointer;
+ }
+ &:is(.input > *, .select > *) {
+ display: flex;
+ height: calc(100% - 0.5rem);
+ align-items: center;
+ padding-inline: calc(0.25rem * 3);
+ white-space: nowrap;
+ font-size: inherit;
+ &:first-child {
+ margin-inline-start: calc(0.25rem * -3);
+ margin-inline-end: calc(0.25rem * 3);
+ border-inline-end: var(--border) solid currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ border-inline-end: var(--border) solid color-mix(in oklab, currentColor 10%, #0000);
+ }
+ }
+ &:last-child {
+ margin-inline-start: calc(0.25rem * 3);
+ margin-inline-end: calc(0.25rem * -3);
+ border-inline-start: var(--border) solid currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ border-inline-start: var(--border) solid color-mix(in oklab, currentColor 10%, #0000);
+ }
+ }
+ }
+ }
+ }
+ .status {
+ @layer daisyui.l1.l2.l3 {
+ display: inline-block;
+ aspect-ratio: 1 / 1;
+ width: calc(0.25rem * 2);
+ height: calc(0.25rem * 2);
+ border-radius: var(--radius-selector);
+ background-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ }
+ background-position: center;
+ background-repeat: no-repeat;
+ vertical-align: middle;
+ color: color-mix(in srgb, #000 30%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-black) 30%, transparent);
+ }
+ background-image: radial-gradient( circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * 0.5)), #0000 );
+ box-shadow: 0 2px 3px -1px currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);
+ }
+ }
+ }
+ .footer {
+ @layer daisyui.l1.l2.l3 {
+ display: grid;
+ width: 100%;
+ grid-auto-flow: row;
+ place-items: start;
+ column-gap: calc(0.25rem * 4);
+ row-gap: calc(0.25rem * 10);
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ & > * {
+ display: grid;
+ place-items: start;
+ gap: calc(0.25rem * 2);
+ }
+ &.footer-center {
+ grid-auto-flow: column dense;
+ place-items: center;
+ text-align: center;
+ & > * {
+ place-items: center;
+ }
+ }
+ }
+ }
+ .card-title {
+ @layer daisyui.l1.l2.l3 {
+ display: flex;
+ align-items: center;
+ gap: calc(0.25rem * 2);
+ font-size: var(--cardtitle-fs, 1.125rem);
+ font-weight: 600;
+ }
}
.grid {
display: grid;
@@ -199,21 +928,26 @@
.hidden {
display: none;
}
- .table {
- display: table;
- }
- .border-collapse {
- border-collapse: collapse;
- }
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
- .resize {
- resize: both;
- }
- .border {
- border-style: var(--tw-border-style);
- border-width: 1px;
+ .link {
+ @layer daisyui.l1.l2.l3 {
+ cursor: pointer;
+ text-decoration-line: underline;
+ &:focus {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+ }
+ }
}
.p-6 {
padding: calc(var(--spacing) * 6);
@@ -221,17 +955,352 @@
.text-center {
text-align: center;
}
- .underline {
- text-decoration-line: underline;
+}
+@layer base {
+ :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
+ color-scheme: light;
+ --color-base-100: oklch(100% 0 0);
+ --color-base-200: oklch(98% 0 0);
+ --color-base-300: oklch(95% 0 0);
+ --color-base-content: oklch(21% 0.006 285.885);
+ --color-primary: oklch(45% 0.24 277.023);
+ --color-primary-content: oklch(93% 0.034 272.788);
+ --color-secondary: oklch(65% 0.241 354.308);
+ --color-secondary-content: oklch(94% 0.028 342.258);
+ --color-accent: oklch(77% 0.152 181.912);
+ --color-accent-content: oklch(38% 0.063 188.416);
+ --color-neutral: oklch(14% 0.005 285.823);
+ --color-neutral-content: oklch(92% 0.004 286.32);
+ --color-info: oklch(74% 0.16 232.661);
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: oklch(76% 0.177 163.223);
+ --color-success-content: oklch(37% 0.077 168.94);
+ --color-warning: oklch(82% 0.189 84.429);
+ --color-warning-content: oklch(41% 0.112 45.904);
+ --color-error: oklch(71% 0.194 13.428);
+ --color-error-content: oklch(27% 0.105 12.094);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
}
- .outline {
- outline-style: var(--tw-outline-style);
- outline-width: 1px;
+}
+@layer base {
+ @media (prefers-color-scheme: dark) {
+ :root:not([data-theme]) {
+ color-scheme: dark;
+ --color-base-100: oklch(25.33% 0.016 252.42);
+ --color-base-200: oklch(23.26% 0.014 253.1);
+ --color-base-300: oklch(21.15% 0.012 254.09);
+ --color-base-content: oklch(97.807% 0.029 256.847);
+ --color-primary: oklch(58% 0.233 277.117);
+ --color-primary-content: oklch(96% 0.018 272.314);
+ --color-secondary: oklch(65% 0.241 354.308);
+ --color-secondary-content: oklch(94% 0.028 342.258);
+ --color-accent: oklch(77% 0.152 181.912);
+ --color-accent-content: oklch(38% 0.063 188.416);
+ --color-neutral: oklch(14% 0.005 285.823);
+ --color-neutral-content: oklch(92% 0.004 286.32);
+ --color-info: oklch(74% 0.16 232.661);
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: oklch(76% 0.177 163.223);
+ --color-success-content: oklch(37% 0.077 168.94);
+ --color-warning: oklch(82% 0.189 84.429);
+ --color-warning-content: oklch(41% 0.112 45.904);
+ --color-error: oklch(71% 0.194 13.428);
+ --color-error-content: oklch(27% 0.105 12.094);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
}
- .transition {
- transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events;
- transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
- transition-duration: var(--tw-duration, var(--default-transition-duration));
+}
+@layer base {
+ :root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
+ color-scheme: light;
+ --color-base-100: oklch(100% 0 0);
+ --color-base-200: oklch(98% 0 0);
+ --color-base-300: oklch(95% 0 0);
+ --color-base-content: oklch(21% 0.006 285.885);
+ --color-primary: oklch(45% 0.24 277.023);
+ --color-primary-content: oklch(93% 0.034 272.788);
+ --color-secondary: oklch(65% 0.241 354.308);
+ --color-secondary-content: oklch(94% 0.028 342.258);
+ --color-accent: oklch(77% 0.152 181.912);
+ --color-accent-content: oklch(38% 0.063 188.416);
+ --color-neutral: oklch(14% 0.005 285.823);
+ --color-neutral-content: oklch(92% 0.004 286.32);
+ --color-info: oklch(74% 0.16 232.661);
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: oklch(76% 0.177 163.223);
+ --color-success-content: oklch(37% 0.077 168.94);
+ --color-warning: oklch(82% 0.189 84.429);
+ --color-warning-content: oklch(41% 0.112 45.904);
+ --color-error: oklch(71% 0.194 13.428);
+ --color-error-content: oklch(27% 0.105 12.094);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
+}
+@layer base {
+ :root:has(input.theme-controller[value=dark]:checked),[data-theme=dark] {
+ color-scheme: dark;
+ --color-base-100: oklch(25.33% 0.016 252.42);
+ --color-base-200: oklch(23.26% 0.014 253.1);
+ --color-base-300: oklch(21.15% 0.012 254.09);
+ --color-base-content: oklch(97.807% 0.029 256.847);
+ --color-primary: oklch(58% 0.233 277.117);
+ --color-primary-content: oklch(96% 0.018 272.314);
+ --color-secondary: oklch(65% 0.241 354.308);
+ --color-secondary-content: oklch(94% 0.028 342.258);
+ --color-accent: oklch(77% 0.152 181.912);
+ --color-accent-content: oklch(38% 0.063 188.416);
+ --color-neutral: oklch(14% 0.005 285.823);
+ --color-neutral-content: oklch(92% 0.004 286.32);
+ --color-info: oklch(74% 0.16 232.661);
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: oklch(76% 0.177 163.223);
+ --color-success-content: oklch(37% 0.077 168.94);
+ --color-warning: oklch(82% 0.189 84.429);
+ --color-warning-content: oklch(41% 0.112 45.904);
+ --color-error: oklch(71% 0.194 13.428);
+ --color-error-content: oklch(27% 0.105 12.094);
+ --radius-selector: 0.5rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
+}
+@layer base {
+ :root {
+ --fx-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E");
+ }
+}
+@layer base {
+ :root {
+ scrollbar-color: currentColor #0000;
+ @supports (color: color-mix(in lab, red, red)) {
+ scrollbar-color: color-mix(in oklch, currentColor 35%, #0000) #0000;
+ }
+ }
+}
+@layer base {
+ @property --radialprogress {
+ syntax: "";
+ inherits: true;
+ initial-value: 0%;
+ }
+}
+@layer base {
+ :root:not(span) {
+ overflow: var(--page-overflow);
+ }
+}
+@layer base {
+ :root {
+ background: var(--page-scroll-bg, var(--root-bg));
+ --page-scroll-bg-on: linear-gradient(var(--root-bg, #0000), var(--root-bg, #0000))
+ var(--root-bg, #0000);
+ @supports (color: color-mix(in lab, red, red)) {
+ --page-scroll-bg-on: linear-gradient(var(--root-bg, #0000), var(--root-bg, #0000))
+ color-mix(in srgb, var(--root-bg, #0000), oklch(0% 0 0) calc(var(--page-has-backdrop, 0) * 40%));
+ }
+ --page-scroll-transition-on: background-color 0.3s ease-out;
+ transition: var(--page-scroll-transition);
+ scrollbar-gutter: var(--page-scroll-gutter, unset);
+ scrollbar-gutter: if(style(--page-has-scroll: 1): var(--page-scroll-gutter, unset) ; else: unset);
+ }
+ @keyframes set-page-has-scroll {
+ 0%, to {
+ --page-has-scroll: 1;
+ }
+ }
+}
+@layer base {
+ :root, [data-theme] {
+ background: var(--page-scroll-bg, var(--root-bg));
+ color: var(--color-base-content);
+ }
+ :where(:root, [data-theme]) {
+ --root-bg: var(--color-base-100);
+ }
+}
+@keyframes rating {
+ 0%, 40% {
+ scale: 1.1;
+ filter: brightness(1.05) contrast(1.05);
+ }
+}
+@keyframes dropdown {
+ 0% {
+ opacity: 0;
+ }
+}
+@keyframes radio {
+ 0% {
+ padding: 5px;
+ }
+ 50% {
+ padding: 3px;
+ }
+}
+@keyframes toast {
+ 0% {
+ scale: 0.9;
+ opacity: 0;
+ }
+ 100% {
+ scale: 1;
+ opacity: 1;
+ }
+}
+@keyframes rotator {
+ 89.9999%, 100% {
+ --first-item-position: 0 0%;
+ }
+ 90%, 99.9999% {
+ --first-item-position: 0 calc(var(--items) * 100%);
+ }
+ 100% {
+ translate: 0 -100%;
+ }
+}
+@keyframes skeleton {
+ 0% {
+ background-position: 150%;
+ }
+ 100% {
+ background-position: -50%;
+ }
+}
+@keyframes menu {
+ 0% {
+ opacity: 0;
+ }
+}
+@keyframes progress {
+ 50% {
+ background-position-x: -115%;
+ }
+}
+@layer base {
+ @media (prefers-color-scheme: dark) {
+ :root:not([data-theme]) {
+ color-scheme: dark;
+ --color-base-100: oklch(18% 0.02 260);
+ --color-base-200: oklch(14% 0.02 260);
+ --color-base-300: oklch(11% 0.02 260);
+ --color-base-content: oklch(90% 0.01 260);
+ --color-primary: oklch(62% 0.26 275);
+ --color-primary-content: oklch(98% 0.01 275);
+ --color-secondary: oklch(68% 0.18 25);
+ --color-secondary-content: oklch(98% 0.01 25);
+ --color-accent: oklch(72% 0.15 185);
+ --color-accent-content: oklch(12% 0.03 185);
+ --color-neutral: oklch(25% 0.02 260);
+ --color-neutral-content: oklch(85% 0.01 260);
+ --color-info: oklch(70% 0.18 230);
+ --color-info-content: oklch(98% 0.01 230);
+ --color-success: oklch(68% 0.19 145);
+ --color-success-content: oklch(98% 0.01 145);
+ --color-warning: oklch(82% 0.22 85);
+ --color-warning-content: oklch(18% 0.04 85);
+ --color-error: oklch(65% 0.26 25);
+ --color-error-content: oklch(98% 0.01 25);
+ --radius-selector: 0.25rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
+ }
+}
+@layer base {
+ :where(:root),:root:has(input.theme-controller[value=certifai-dark]:checked),[data-theme="certifai-dark"] {
+ color-scheme: dark;
+ --color-base-100: oklch(18% 0.02 260);
+ --color-base-200: oklch(14% 0.02 260);
+ --color-base-300: oklch(11% 0.02 260);
+ --color-base-content: oklch(90% 0.01 260);
+ --color-primary: oklch(62% 0.26 275);
+ --color-primary-content: oklch(98% 0.01 275);
+ --color-secondary: oklch(68% 0.18 25);
+ --color-secondary-content: oklch(98% 0.01 25);
+ --color-accent: oklch(72% 0.15 185);
+ --color-accent-content: oklch(12% 0.03 185);
+ --color-neutral: oklch(25% 0.02 260);
+ --color-neutral-content: oklch(85% 0.01 260);
+ --color-info: oklch(70% 0.18 230);
+ --color-info-content: oklch(98% 0.01 230);
+ --color-success: oklch(68% 0.19 145);
+ --color-success-content: oklch(98% 0.01 145);
+ --color-warning: oklch(82% 0.22 85);
+ --color-warning-content: oklch(18% 0.04 85);
+ --color-error: oklch(65% 0.26 25);
+ --color-error-content: oklch(98% 0.01 25);
+ --radius-selector: 0.25rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+ }
+}
+@layer base {
+ :root:has(input.theme-controller[value=certifai-light]:checked),[data-theme="certifai-light"] {
+ color-scheme: light;
+ --color-base-100: oklch(98% 0.005 260);
+ --color-base-200: oklch(95% 0.008 260);
+ --color-base-300: oklch(91% 0.012 260);
+ --color-base-content: oklch(20% 0.03 260);
+ --color-primary: oklch(50% 0.26 275);
+ --color-primary-content: oklch(98% 0.01 275);
+ --color-secondary: oklch(58% 0.18 25);
+ --color-secondary-content: oklch(98% 0.01 25);
+ --color-accent: oklch(55% 0.15 185);
+ --color-accent-content: oklch(98% 0.01 185);
+ --color-neutral: oklch(35% 0.02 260);
+ --color-neutral-content: oklch(98% 0.01 260);
+ --color-info: oklch(55% 0.18 230);
+ --color-info-content: oklch(98% 0.01 230);
+ --color-success: oklch(52% 0.19 145);
+ --color-success-content: oklch(98% 0.01 145);
+ --color-warning: oklch(72% 0.22 85);
+ --color-warning-content: oklch(18% 0.04 85);
+ --color-error: oklch(55% 0.26 25);
+ --color-error-content: oklch(98% 0.01 25);
+ --radius-selector: 0.25rem;
+ --radius-field: 0.25rem;
+ --radius-box: 0.5rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
}
}
@property --tw-rotate-x {
@@ -254,16 +1323,6 @@
syntax: "*";
inherits: false;
}
-@property --tw-border-style {
- syntax: "*";
- inherits: false;
- initial-value: solid;
-}
-@property --tw-outline-style {
- syntax: "*";
- inherits: false;
- initial-value: solid;
-}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
@@ -272,8 +1331,6 @@
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
- --tw-border-style: solid;
- --tw-outline-style: solid;
}
}
}
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..5d6c3e9
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,33 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "certifai",
+ "dependencies": {
+ "daisyui": "^5.5.18",
+ "tailwindcss": "^4.1.18",
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ },
+ "peerDependencies": {
+ "typescript": "^5",
+ },
+ },
+ },
+ "packages": {
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
+
+ "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
+
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
+
+ "daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
+
+ "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ }
+}
diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json
new file mode 100644
index 0000000..4b1c52a
--- /dev/null
+++ b/keycloak/realm-export.json
@@ -0,0 +1,246 @@
+{
+ "id": "certifai",
+ "realm": "certifai",
+ "displayName": "CERTifAI",
+ "enabled": true,
+ "sslRequired": "none",
+ "registrationAllowed": true,
+ "registrationEmailAsUsername": true,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": true,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": true,
+ "permanentLockout": false,
+ "maxFailureWaitSeconds": 900,
+ "minimumQuickLoginWaitSeconds": 60,
+ "waitIncrementSeconds": 60,
+ "quickLoginCheckMilliSeconds": 1000,
+ "maxDeltaTimeSeconds": 43200,
+ "failureFactor": 5,
+ "defaultSignatureAlgorithm": "RS256",
+ "accessTokenLifespan": 300,
+ "ssoSessionIdleTimeout": 1800,
+ "ssoSessionMaxLifespan": 36000,
+ "offlineSessionIdleTimeout": 2592000,
+ "accessCodeLifespan": 60,
+ "accessCodeLifespanUserAction": 300,
+ "accessCodeLifespanLogin": 1800,
+ "roles": {
+ "realm": [
+ {
+ "name": "admin",
+ "description": "CERTifAI administrator with full access",
+ "composite": false,
+ "clientRole": false
+ },
+ {
+ "name": "user",
+ "description": "Standard CERTifAI user",
+ "composite": false,
+ "clientRole": false
+ }
+ ]
+ },
+ "defaultRoles": [
+ "user"
+ ],
+ "clients": [
+ {
+ "clientId": "certifai-dashboard",
+ "name": "CERTifAI Dashboard",
+ "description": "CERTifAI administration dashboard",
+ "enabled": true,
+ "publicClient": true,
+ "directAccessGrantsEnabled": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "serviceAccountsEnabled": false,
+ "protocol": "openid-connect",
+ "rootUrl": "http://localhost:8000",
+ "baseUrl": "http://localhost:8000",
+ "redirectUris": [
+ "http://localhost:8000/auth/callback"
+ ],
+ "webOrigins": [
+ "http://localhost:8000"
+ ],
+ "attributes": {
+ "post.logout.redirect.uris": "http://localhost:8000",
+ "pkce.code.challenge.method": "S256"
+ },
+ "defaultClientScopes": [
+ "openid",
+ "profile",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "offline_access"
+ ]
+ }
+ ],
+ "clientScopes": [
+ {
+ "name": "openid",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "sub",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-sub-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "name": "profile",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "User profile information"
+ },
+ "protocolMappers": [
+ {
+ "name": "full name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-full-name-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ },
+ {
+ "name": "given name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "firstName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "claim.name": "given_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "family name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "lastName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "claim.name": "family_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "picture",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "picture",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "claim.name": "picture",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "Email address"
+ },
+ "protocolMappers": [
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "email verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "emailVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "claim.name": "email_verified",
+ "jsonType.label": "boolean"
+ }
+ }
+ ]
+ }
+ ],
+ "users": [
+ {
+ "username": "admin@certifai.local",
+ "email": "admin@certifai.local",
+ "firstName": "Admin",
+ "lastName": "User",
+ "enabled": true,
+ "emailVerified": true,
+ "credentials": [
+ {
+ "type": "password",
+ "value": "admin",
+ "temporary": false
+ }
+ ],
+ "realmRoles": [
+ "admin",
+ "user"
+ ]
+ },
+ {
+ "username": "user@certifai.local",
+ "email": "user@certifai.local",
+ "firstName": "Test",
+ "lastName": "User",
+ "enabled": true,
+ "emailVerified": true,
+ "credentials": [
+ {
+ "type": "password",
+ "value": "user",
+ "temporary": false
+ }
+ ],
+ "realmRoles": [
+ "user"
+ ]
+ }
+ ]
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0072209
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "certifai",
+ "module": "index.ts",
+ "type": "module",
+ "private": true,
+ "devDependencies": {
+ "@types/bun": "latest"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ },
+ "dependencies": {
+ "daisyui": "^5.5.18",
+ "tailwindcss": "^4.1.18"
+ }
+}
diff --git a/src/app.rs b/src/app.rs
index cb9a4de..45ad3fa 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -39,8 +39,10 @@ pub fn App() -> Element {
crossorigin: "anonymous",
}
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
- document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
- Router:: {}
+ document::Link { rel: "stylesheet", href: MAIN_CSS }
+ div { "data-theme": "certifai-dark",
+ Router:: {}
+ }
}
}
diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs
index bf8538a..a58f845 100644
--- a/src/infrastructure/auth.rs
+++ b/src/infrastructure/auth.rs
@@ -16,28 +16,37 @@ use crate::infrastructure::{state::User, Error, UserStateInner};
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
-/// In-memory store for pending OAuth states and their associated redirect
-/// URLs. Keyed by the random state string. This avoids dependence on the
-/// session cookie surviving the Keycloak redirect round-trip (the `dx serve`
-/// proxy can drop `Set-Cookie` headers on 307 responses).
+/// Data stored alongside each pending OAuth state. Holds the optional
+/// post-login redirect URL and the PKCE code verifier needed for the
+/// token exchange.
+#[derive(Debug, Clone)]
+struct PendingOAuthEntry {
+ redirect_url: Option,
+ code_verifier: String,
+}
+
+/// In-memory store for pending OAuth states. Keyed by the random state
+/// string. This avoids dependence on the session cookie surviving the
+/// Keycloak redirect round-trip (the `dx serve` proxy can drop
+/// `Set-Cookie` headers on 307 responses).
#[derive(Debug, Clone, Default)]
-pub struct PendingOAuthStore(Arc>>>);
+pub struct PendingOAuthStore(Arc>>);
impl PendingOAuthStore {
- /// Insert a pending state with an optional post-login redirect URL.
- fn insert(&self, state: String, redirect_url: Option) {
+ /// Insert a pending state with an optional redirect URL and PKCE verifier.
+ fn insert(&self, state: String, entry: PendingOAuthEntry) {
// RwLock::write only panics if the lock is poisoned, which
// indicates a prior panic -- propagating is acceptable here.
#[allow(clippy::expect_used)]
self.0
.write()
.expect("pending oauth store lock poisoned")
- .insert(state, redirect_url);
+ .insert(state, entry);
}
- /// Remove and return the redirect URL if the state was pending.
+ /// Remove and return the entry if the state was pending.
/// Returns `None` if the state was never stored (CSRF failure).
- fn take(&self, state: &str) -> Option