feat(ui): added daisy UI for beautification #3
@@ -164,14 +164,8 @@ jobs:
|
|||||||
run: cargo install git-cliff --locked
|
run: cargo install git-cliff --locked
|
||||||
- name: Generate changelog
|
- name: Generate changelog
|
||||||
run: git cliff --output CHANGELOG.md
|
run: git cliff --output CHANGELOG.md
|
||||||
- name: Commit and push changelog
|
- name: Upload changelog artifact
|
||||||
run: |
|
uses: actions/upload-artifact@v4
|
||||||
git config user.name "CI Bot"
|
with:
|
||||||
git config user.email "ci@certifai.local"
|
name: changelog
|
||||||
git add CHANGELOG.md
|
path: CHANGELOG.md
|
||||||
if git diff --cached --quiet; then
|
|
||||||
echo "No changelog changes to commit"
|
|
||||||
else
|
|
||||||
git commit -m "docs: update CHANGELOG.md [skip ci]"
|
|
||||||
git push origin HEAD:"${GITHUB_REF_NAME}"
|
|
||||||
fi
|
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -12,5 +12,9 @@
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Keycloak data
|
# Keycloak runtime data (but keep realm-export.json)
|
||||||
keycloak/
|
keycloak/*
|
||||||
|
!keycloak/realm-export.json
|
||||||
|
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -739,6 +739,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stripe",
|
"async-stripe",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
"dioxus-cli-config",
|
"dioxus-cli-config",
|
||||||
@@ -755,6 +756,7 @@ dependencies = [
|
|||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ dioxus-free-icons = { version = "0.10", features = [
|
|||||||
"bootstrap",
|
"bootstrap",
|
||||||
"font-awesome-solid",
|
"font-awesome-solid",
|
||||||
] }
|
] }
|
||||||
|
sha2 = { version = "0.10.9", default-features = false, optional = true }
|
||||||
|
base64 = { version = "0.22.1", default-features = false, optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# default = ["web"]
|
# default = ["web"]
|
||||||
@@ -87,6 +89,8 @@ server = [
|
|||||||
"dep:time",
|
"dep:time",
|
||||||
"dep:rand",
|
"dep:rand",
|
||||||
"dep:url",
|
"dep:url",
|
||||||
|
"dep:sha2",
|
||||||
|
"dep:base64",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -19,12 +19,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
RUN curl -fsSL https://bun.sh/install | bash
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
ENV PATH="/root/.bun/bin:$PATH"
|
ENV PATH="/root/.bun/bin:$PATH"
|
||||||
|
|
||||||
# Install dx CLI via cargo-binstall
|
# Install dx CLI from source (binstall binaries require GLIBC >= 2.38)
|
||||||
RUN curl -L --proto '=https' --tlsv1.2 -sSf \
|
RUN cargo install dioxus-cli@0.7.3 --locked
|
||||||
https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh \
|
|
||||||
| bash
|
|
||||||
RUN cargo binstall dioxus-cli@0.7.3 --root /.cargo -y --force
|
|
||||||
ENV PATH="/.cargo/bin:$PATH"
|
|
||||||
|
|
||||||
# Cook dependencies from recipe (cached layer)
|
# Cook dependencies from recipe (cached layer)
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
@@ -33,11 +29,11 @@ RUN cargo chef cook --release --recipe-path recipe.json
|
|||||||
# Copy source and build
|
# Copy source and build
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Ensure styles directory exists for build.rs tailwind step
|
# Install frontend dependencies (DaisyUI, Tailwind) for the build.rs CSS step
|
||||||
RUN mkdir -p styles && touch styles/input.css
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
# Bundle the fullstack application
|
# Bundle the fullstack application
|
||||||
RUN dx bundle --platform fullstack
|
RUN dx bundle --release --fullstack
|
||||||
|
|
||||||
# Stage 3: Minimal runtime image
|
# Stage 3: Minimal runtime image
|
||||||
FROM debian:bookworm-slim AS runtime
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|||||||
25
assets/logo.svg
Normal file
25
assets/logo.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<!-- Shield body -->
|
||||||
|
<path d="M32 4L8 16v16c0 14.4 10.24 27.2 24 32 13.76-4.8 24-17.6 24-32V16L32 4z"
|
||||||
|
fill="#4B3FE0" fill-opacity="0.12" stroke="#4B3FE0" stroke-width="2"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
<!-- Inner shield highlight -->
|
||||||
|
<path d="M32 10L14 19v11c0 11.6 7.68 22 18 26 10.32-4 18-14.4 18-26V19L32 10z"
|
||||||
|
fill="none" stroke="#4B3FE0" stroke-width="1" stroke-opacity="0.3"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
<!-- Neural network nodes -->
|
||||||
|
<circle cx="32" cy="24" r="3.5" fill="#38B2AC"/>
|
||||||
|
<circle cx="22" cy="36" r="3" fill="#38B2AC"/>
|
||||||
|
<circle cx="42" cy="36" r="3" fill="#38B2AC"/>
|
||||||
|
<circle cx="27" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
|
||||||
|
<circle cx="37" cy="48" r="2.5" fill="#38B2AC" fill-opacity="0.7"/>
|
||||||
|
<!-- Neural network edges -->
|
||||||
|
<line x1="32" y1="24" x2="22" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||||
|
<line x1="32" y1="24" x2="42" y2="36" stroke="#38B2AC" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||||
|
<line x1="22" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||||
|
<line x1="22" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||||
|
<line x1="42" y1="36" x2="27" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||||
|
<line x1="42" y1="36" x2="37" y2="48" stroke="#38B2AC" stroke-width="1" stroke-opacity="0.4"/>
|
||||||
|
<!-- Cross edge for connectivity -->
|
||||||
|
<line x1="22" y1="36" x2="42" y2="36" stroke="#38B2AC" stroke-width="0.8" stroke-opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
1149
assets/tailwind.css
1149
assets/tailwind.css
File diff suppressed because it is too large
Load Diff
33
bun.lock
Normal file
33
bun.lock
Normal file
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
246
keycloak/realm-export.json
Normal file
246
keycloak/realm-export.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
package.json
Normal file
16
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,8 @@ pub fn App() -> Element {
|
|||||||
crossorigin: "anonymous",
|
crossorigin: "anonymous",
|
||||||
}
|
}
|
||||||
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
|
document::Link { rel: "stylesheet", href: GOOGLE_FONTS }
|
||||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
|
||||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||||
Router::<Route> {}
|
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||||
|
div { "data-theme": "certifai-dark", Router::<Route> {} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,28 +16,37 @@ use crate::infrastructure::{state::User, Error, UserStateInner};
|
|||||||
|
|
||||||
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||||
|
|
||||||
/// In-memory store for pending OAuth states and their associated redirect
|
/// Data stored alongside each pending OAuth state. Holds the optional
|
||||||
/// URLs. Keyed by the random state string. This avoids dependence on the
|
/// post-login redirect URL and the PKCE code verifier needed for the
|
||||||
/// session cookie surviving the Keycloak redirect round-trip (the `dx serve`
|
/// token exchange.
|
||||||
/// proxy can drop `Set-Cookie` headers on 307 responses).
|
#[derive(Debug, Clone)]
|
||||||
|
struct PendingOAuthEntry {
|
||||||
|
redirect_url: Option<String>,
|
||||||
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, Option<String>>>>);
|
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
||||||
|
|
||||||
impl PendingOAuthStore {
|
impl PendingOAuthStore {
|
||||||
/// Insert a pending state with an optional post-login redirect URL.
|
/// Insert a pending state with an optional redirect URL and PKCE verifier.
|
||||||
fn insert(&self, state: String, redirect_url: Option<String>) {
|
fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
||||||
// RwLock::write only panics if the lock is poisoned, which
|
// RwLock::write only panics if the lock is poisoned, which
|
||||||
// indicates a prior panic -- propagating is acceptable here.
|
// indicates a prior panic -- propagating is acceptable here.
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
self.0
|
self.0
|
||||||
.write()
|
.write()
|
||||||
.expect("pending oauth store lock poisoned")
|
.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).
|
/// Returns `None` if the state was never stored (CSRF failure).
|
||||||
fn take(&self, state: &str) -> Option<Option<String>> {
|
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
self.0
|
self.0
|
||||||
.write()
|
.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.
|
/// Redirect the user to Keycloak's authorization page.
|
||||||
///
|
///
|
||||||
/// Generates a random CSRF state, stores it (along with the optional
|
/// Generates a random CSRF state, stores it (along with the optional
|
||||||
@@ -142,9 +173,17 @@ pub async fn auth_login(
|
|||||||
) -> Result<impl IntoResponse, Error> {
|
) -> Result<impl IntoResponse, Error> {
|
||||||
let config = OAuthConfig::from_env()?;
|
let config = OAuthConfig::from_env()?;
|
||||||
let state = generate_state();
|
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();
|
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())
|
let mut url = Url::parse(&config.auth_endpoint())
|
||||||
.map_err(|e| Error::StateError(format!("invalid auth endpoint URL: {e}")))?;
|
.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("redirect_uri", &config.redirect_uri)
|
||||||
.append_pair("response_type", "code")
|
.append_pair("response_type", "code")
|
||||||
.append_pair("scope", "openid profile email")
|
.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()))
|
Ok(Redirect::temporary(url.as_str()))
|
||||||
}
|
}
|
||||||
@@ -203,11 +244,11 @@ pub async fn auth_callback(
|
|||||||
.get("state")
|
.get("state")
|
||||||
.ok_or_else(|| Error::StateError("missing state parameter".into()))?;
|
.ok_or_else(|| Error::StateError("missing state parameter".into()))?;
|
||||||
|
|
||||||
let redirect_url = pending
|
let entry = pending
|
||||||
.take(returned_state)
|
.take(returned_state)
|
||||||
.ok_or_else(|| Error::StateError("unknown or expired oauth state".into()))?;
|
.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
|
let code = params
|
||||||
.get("code")
|
.get("code")
|
||||||
.ok_or_else(|| Error::StateError("missing code parameter".into()))?;
|
.ok_or_else(|| Error::StateError("missing code parameter".into()))?;
|
||||||
@@ -220,6 +261,7 @@ pub async fn auth_callback(
|
|||||||
("client_id", &config.client_id),
|
("client_id", &config.client_id),
|
||||||
("redirect_uri", &config.redirect_uri),
|
("redirect_uri", &config.redirect_uri),
|
||||||
("code", code),
|
("code", code),
|
||||||
|
("code_verifier", &entry.code_verifier),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -259,7 +301,8 @@ pub async fn auth_callback(
|
|||||||
|
|
||||||
set_login_session(session, user_state).await?;
|
set_login_session(session, user_state).await?;
|
||||||
|
|
||||||
let target = redirect_url
|
let target = entry
|
||||||
|
.redirect_url
|
||||||
.filter(|u| !u.is_empty())
|
.filter(|u| !u.is_empty())
|
||||||
.unwrap_or_else(|| "/".into());
|
.unwrap_or_else(|| "/".into());
|
||||||
|
|
||||||
|
|||||||
112
styles/input.css
Normal file
112
styles/input.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
Reference in New Issue
Block a user