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> { + fn take(&self, state: &str) -> Option { #[allow(clippy::expect_used)] self.0 .write() @@ -122,6 +131,28 @@ fn generate_state() -> String { }) } +/// Generate a PKCE code verifier (43-128 char URL-safe random string). +/// +/// Uses 32 random bytes encoded as base64url (no padding) to produce +/// a 43-character verifier per RFC 7636. +fn generate_code_verifier() -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + + let bytes: [u8; 32] = rand::rng().random(); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Derive the S256 code challenge from a code verifier per RFC 7636. +/// +/// `code_challenge = BASE64URL(SHA256(code_verifier))` +fn derive_code_challenge(verifier: &str) -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use sha2::{Digest, Sha256}; + + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + /// Redirect the user to Keycloak's authorization page. /// /// Generates a random CSRF state, stores it (along with the optional @@ -142,9 +173,17 @@ pub async fn auth_login( ) -> Result { let config = OAuthConfig::from_env()?; let state = generate_state(); + let code_verifier = generate_code_verifier(); + let code_challenge = derive_code_challenge(&code_verifier); let redirect_url = params.get("redirect_url").cloned(); - pending.insert(state.clone(), redirect_url); + pending.insert( + state.clone(), + PendingOAuthEntry { + redirect_url, + code_verifier, + }, + ); let mut url = Url::parse(&config.auth_endpoint()) .map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?; @@ -154,7 +193,9 @@ pub async fn auth_login( .append_pair("redirect_uri", &config.redirect_uri) .append_pair("response_type", "code") .append_pair("scope", "openid profile email") - .append_pair("state", &state); + .append_pair("state", &state) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256"); Ok(Redirect::temporary(url.as_str())) } @@ -203,11 +244,11 @@ pub async fn auth_callback( .get("state") .ok_or_else(|| Error::StateError("missing state parameter".into()))?; - let redirect_url = pending + let entry = pending .take(returned_state) .ok_or_else(|| Error::StateError("unknown or expired oauth state".into()))?; - // --- Exchange code for tokens --- + // --- Exchange code for tokens (with PKCE code_verifier) --- let code = params .get("code") .ok_or_else(|| Error::StateError("missing code parameter".into()))?; @@ -220,6 +261,7 @@ pub async fn auth_callback( ("client_id", &config.client_id), ("redirect_uri", &config.redirect_uri), ("code", code), + ("code_verifier", &entry.code_verifier), ]) .send() .await @@ -259,7 +301,8 @@ pub async fn auth_callback( set_login_session(session, user_state).await?; - let target = redirect_url + let target = entry + .redirect_url .filter(|u| !u.is_empty()) .unwrap_or_else(|| "/".into()); diff --git a/styles/input.css b/styles/input.css new file mode 100644 index 0000000..b038858 --- /dev/null +++ b/styles/input.css @@ -0,0 +1,112 @@ +@import "tailwindcss"; +@plugin "daisyui"; + +/* ===== CERTifAI Dark Theme (default) ===== */ +@plugin "daisyui/theme" { + name: "certifai-dark"; + default: true; + prefersdark: true; + color-scheme: dark; + + /* Base: deep navy-charcoal */ + --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); + + /* Primary: electric indigo */ + --color-primary: oklch(62% 0.26 275); + --color-primary-content: oklch(98% 0.01 275); + + /* Secondary: coral */ + --color-secondary: oklch(68% 0.18 25); + --color-secondary-content: oklch(98% 0.01 25); + + /* Accent: teal */ + --color-accent: oklch(72% 0.15 185); + --color-accent-content: oklch(12% 0.03 185); + + /* Neutral */ + --color-neutral: oklch(25% 0.02 260); + --color-neutral-content: oklch(85% 0.01 260); + + /* Semantic */ + --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); + + /* Sharp, modern radii */ + --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; +} + +/* ===== CERTifAI Light Theme ===== */ +@plugin "daisyui/theme" { + name: "certifai-light"; + default: false; + prefersdark: false; + color-scheme: light; + + /* Base: clean off-white */ + --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); + + /* Primary: indigo (adjusted for light bg) */ + --color-primary: oklch(50% 0.26 275); + --color-primary-content: oklch(98% 0.01 275); + + /* Secondary: coral (adjusted for light bg) */ + --color-secondary: oklch(58% 0.18 25); + --color-secondary-content: oklch(98% 0.01 25); + + /* Accent: teal (adjusted for light bg) */ + --color-accent: oklch(55% 0.15 185); + --color-accent-content: oklch(98% 0.01 185); + + /* Neutral */ + --color-neutral: oklch(35% 0.02 260); + --color-neutral-content: oklch(98% 0.01 260); + + /* Semantic */ + --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); + + /* Same sharp radii */ + --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; +} diff --git a/tailwind.css b/tailwind.css deleted file mode 100644 index f1d8c73..0000000 --- a/tailwind.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss";