Compare commits

..

11 Commits

Author SHA1 Message Date
Teffen Ellis
feb0a93ef0 web: Flesh out a11y in wizard elements. 2025-08-26 21:31:51 +02:00
Teffen Ellis
36bf0a762a web/a11y: Text Input (#16041)
web: Flesh out input clean up.
2025-08-26 15:30:06 -04:00
Jens L.
61db0fa760 ci: fix cherry-pick quoting PR body causing variable expansion (#16376)
* set pipefail etc just to be sure

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* dont quote PR body

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* dont run on issue label

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-08-26 20:26:27 +01:00
Jens L.
04f883c8da ci: fix missing triggers for cherry-pick (#16375)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-08-26 20:16:01 +01:00
Jens L.
6d097feac0 ci: fix cherry-pick GHA to work on merged PRs (#16373)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-08-26 19:55:36 +01:00
Teffen Ellis
6a2e07eb6a web/a11y: File Inputs (#16038)
web: Prep for a11y.
2025-08-26 20:12:45 +02:00
Teffen Ellis
232f52b349 web/e2e: Playwright end-to-end test runner (#16014)
* web: Flesh out Playwright.

web: Flesh out slim tests.

* web/e2e: Sessions

* web: Update tests.

* web: Fix missing git hash when using docker as backend.

* Fix selectors.

* web: Flesh out a11y in wizard elements.

* web: Flesh out provider tests.
2025-08-26 17:09:00 +00:00
Jens L.
baeb892a22 ci: Add label-based cherry-pick (#16370)
* yaml consistency

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* create backport label on branch off

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ci: add cherry-pick action

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Update .github/actions/cherry-pick/action.yml

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Jens L. <jens@beryju.org>

* Apply suggestions from code review

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Jens L. <jens@beryju.org>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-08-26 17:25:36 +01:00
Teffen Ellis
810aa1cfea website: Fix stale links that depend on initial route not triggering redirects. (#16369)
website: Fix issue where stale links that depend on initial route are
not redirected.
2025-08-26 12:21:22 -04:00
Teffen Ellis
04a8357708 web: Automatic reload during server start up. (#16030)
* web: Automatic reload during server start up.

* web: Flesh out reload behavior.

* web: Flesh out wave boi.
2025-08-26 15:13:22 +00:00
Connor Peshek
a8ecc9b530 website/docs: Add steps for fixing xml python errors, clean up (#16223) 2025-08-26 08:42:05 -05:00
66 changed files with 3519 additions and 3305 deletions

263
.github/actions/cherry-pick/action.yml vendored Normal file
View File

@@ -0,0 +1,263 @@
name: "Cherry-picker"
description: "Cherry-pick PRs based on their labels"
inputs:
token:
description: "GitHub Token"
required: true
runs:
using: "composite"
steps:
- name: Check if workflow should run
id: should_run
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.token }}
run: |
set -e -o pipefail
# For issues events, check if it's actually a PR
if [ "${{ github.event_name }}" = "issues" ]; then
# Check if this issue is actually a PR
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }} 2>/dev/null || echo "null")
if [ "$PR_DATA" = "null" ]; then
echo "should_run=false" >> $GITHUB_OUTPUT
echo "reason=not_a_pr" >> $GITHUB_OUTPUT
echo "This is an issue, not a PR. Skipping."
exit 0
fi
# Get PR data
PR_MERGED=$(echo "$PR_DATA" | jq -r '.merged')
PR_NUMBER="${{ github.event.issue.number }}"
MERGE_COMMIT_SHA=$(echo "$PR_DATA" | jq -r '.merge_commit_sha')
# Check if it's a backport label
LABEL_NAME="${{ github.event.label.name }}"
if [[ "$LABEL_NAME" =~ ^backport/(.+)$ ]]; then
if [ "$PR_MERGED" = "true" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "reason=label_added_to_merged_pr" >> $GITHUB_OUTPUT
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "merge_commit_sha=$MERGE_COMMIT_SHA" >> $GITHUB_OUTPUT
exit 0
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "reason=label_added_to_open_pr" >> $GITHUB_OUTPUT
echo "Backport label added to open PR. Will run after PR is merged."
exit 0
fi
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "reason=non_backport_label" >> $GITHUB_OUTPUT
exit 0
fi
fi
# For pull_request and pull_request_target events
PR_NUMBER="${{ github.event.pull_request.number }}"
MERGE_COMMIT_SHA="${{ github.event.pull_request.merge_commit_sha }}"
# Case 1: PR was just merged (closed + merged = true)
if [ "${{ github.event.action }}" = "closed" ] && [ "${{ github.event.pull_request.merged }}" = "true" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "reason=pr_merged" >> $GITHUB_OUTPUT
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "merge_commit_sha=$MERGE_COMMIT_SHA" >> $GITHUB_OUTPUT
exit 0
fi
# Case 2: Label was added
if [ "${{ github.event.action }}" = "labeled" ]; then
LABEL_NAME="${{ github.event.label.name }}"
# Check if it's a backport label
if [[ "$LABEL_NAME" =~ ^backport/(.+)$ ]]; then
# Check if PR is already merged
if [ "${{ github.event.pull_request.merged }}" = "true" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "reason=label_added_to_merged_pr" >> $GITHUB_OUTPUT
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "merge_commit_sha=$MERGE_COMMIT_SHA" >> $GITHUB_OUTPUT
exit 0
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "reason=label_added_to_open_pr" >> $GITHUB_OUTPUT
echo "Backport label added to open PR. Will run after PR is merged."
exit 0
fi
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "reason=non_backport_label" >> $GITHUB_OUTPUT
exit 0
fi
fi
echo "should_run=false" >> $GITHUB_OUTPUT
echo "reason=unknown" >> $GITHUB_OUTPUT
- name: Configure Git
if: steps.should_run.outputs.should_run == 'true'
shell: bash
run: |
git config --global user.name "authentik-automation[bot]"
git config --global user.email "135050075+authentik-automation[bot]@users.noreply.github.com"
- name: Get PR details and extract backport labels
if: steps.should_run.outputs.should_run == 'true'
id: pr_details
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.token }}
run: |
set -e -o pipefail
PR_NUMBER="${{ steps.should_run.outputs.pr_number }}"
# Get PR details
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/$PR_NUMBER)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.user.login')
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT
# Determine which labels to process
if [ "${{ steps.should_run.outputs.reason }}" = "label_added_to_merged_pr" ]; then
# Only process the specific label that was just added
if [ "${{ github.event_name }}" = "issues" ]; then
LABEL_NAME="${{ github.event.label.name }}"
else
LABEL_NAME="${{ github.event.label.name }}"
fi
if [[ "$LABEL_NAME" =~ ^backport/(.+)$ ]]; then
echo "labels=$LABEL_NAME" >> $GITHUB_OUTPUT
else
echo "Label $LABEL_NAME does not match backport pattern"
echo "labels=" >> $GITHUB_OUTPUT
fi
else
# PR was just merged, process all backport labels
LABELS=$(gh pr view $PR_NUMBER --json labels --jq '.labels[].name' | grep '^backport/' | tr '\n' ' ' || true)
echo "labels=$LABELS" >> $GITHUB_OUTPUT
fi
- name: Cherry-pick to target branches
if: steps.should_run.outputs.should_run == 'true' && steps.pr_details.outputs.labels != ''
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.token }}
run: |
set -e -o pipefail
PR_NUMBER="${{ steps.should_run.outputs.pr_number }}"
COMMIT_SHA="${{ steps.should_run.outputs.merge_commit_sha }}"
PR_TITLE="${{ steps.pr_details.outputs.pr_title }}"
PR_AUTHOR="${{ steps.pr_details.outputs.pr_author }}"
LABELS="${{ steps.pr_details.outputs.labels }}"
echo "Processing PR #$PR_NUMBER (reason: ${{ steps.should_run.outputs.reason }})"
echo "Found backport labels: $LABELS"
# Process each backport label
for label in $LABELS; do
if [[ "$label" =~ ^backport/(.+)$ ]]; then
TARGET_BRANCH="${BASH_REMATCH[1]}"
echo "Processing backport to branch: $TARGET_BRANCH"
# Check if target branch exists
if ! git ls-remote --heads origin "$TARGET_BRANCH" | grep -q "$TARGET_BRANCH"; then
echo "❌ Target branch $TARGET_BRANCH does not exist, skipping"
# Comment on the original PR about the missing branch
gh pr comment $PR_NUMBER --body "⚠️ Cannot backport to \`$TARGET_BRANCH\`: branch does not exist."
continue
fi
# Create a unique branch name for the cherry-pick
CHERRY_PICK_BRANCH="cherry-pick-${PR_NUMBER}-to-${TARGET_BRANCH}"
# Check if a cherry-pick PR already exists
EXISTING_PR=$(gh pr list --head "$CHERRY_PICK_BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
echo "⚠️ Cherry-pick PR already exists: #$EXISTING_PR"
gh pr comment $PR_NUMBER --body "Cherry-pick to \`$TARGET_BRANCH\` already exists: #$EXISTING_PR"
continue
fi
# Fetch and checkout target branch
git fetch origin "$TARGET_BRANCH"
git checkout -b "$CHERRY_PICK_BRANCH" "origin/$TARGET_BRANCH"
# Attempt cherry-pick
if git cherry-pick "$COMMIT_SHA"; then
echo "✅ Cherry-pick successful for $TARGET_BRANCH"
# Push the cherry-pick branch
git push origin "$CHERRY_PICK_BRANCH"
# Create PR for the cherry-pick
CHERRY_PICK_TITLE="$PR_TITLE (cherry-pick #$PR_NUMBER)"
CHERRY_PICK_BODY="Cherry-pick of #$PR_NUMBER to \`$TARGET_BRANCH\` branch.
**Original PR:** #$PR_NUMBER
**Original Author:** @$PR_AUTHOR
**Cherry-picked commit:** $COMMIT_SHA"
NEW_PR=$(gh pr create \
--title "$CHERRY_PICK_TITLE" \
--body "$CHERRY_PICK_BODY" \
--base "$TARGET_BRANCH" \
--head "$CHERRY_PICK_BRANCH" \
--label "cherry-pick" \
--label "backport" \
--json number --jq '.number')
echo "✅ Created cherry-pick PR #$NEW_PR for $TARGET_BRANCH"
# Comment on original PR
gh pr comment $PR_NUMBER --body "🍒 Cherry-pick to \`$TARGET_BRANCH\` created: #$NEW_PR"
else
echo "⚠️ Cherry-pick failed for $TARGET_BRANCH, creating conflict resolution PR"
# Add conflicted files and commit
git add .
git commit -m "Cherry-pick #$PR_NUMBER to $TARGET_BRANCH (with conflicts)
This cherry-pick has conflicts that need manual resolution.
Original PR: #$PR_NUMBER
Original commit: $COMMIT_SHA"
# Push the branch with conflicts
git push origin "$CHERRY_PICK_BRANCH"
# Create PR with conflict notice
CONFLICT_TITLE="$PR_TITLE (backport of #$PR_NUMBER)"
CONFLICT_BODY="⚠️ **This cherry-pick has conflicts that require manual resolution.**
Cherry-pick of #$PR_NUMBER to \`$TARGET_BRANCH\` branch.
**Original PR:** #$PR_NUMBER
**Original Author:** @$PR_AUTHOR
**Cherry-picked commit:** $COMMIT_SHA
**Please resolve the conflicts in this PR before merging.**"
NEW_PR=$(gh pr create \
--title "$CONFLICT_TITLE" \
--body "$CONFLICT_BODY" \
--base "$TARGET_BRANCH" \
--head "$CHERRY_PICK_BRANCH" \
--label "cherry-pick" \
--label "backport" \
--label "conflicts" \
--json number --jq '.number')
echo "⚠️ Created conflict resolution PR #$NEW_PR for $TARGET_BRANCH"
# Comment on original PR
gh pr comment $PR_NUMBER --body "⚠️ Cherry-pick to \`$TARGET_BRANCH\` has conflicts: #$NEW_PR"
fi
# Clean up - go back to main branch
git checkout main
git branch -D "$CHERRY_PICK_BRANCH" 2>/dev/null || true
fi
done

View File

@@ -21,7 +21,7 @@ on:
jobs:
build-server-amd64:
uses: ./.github/workflows/_reusable-docker-build-single.yaml
uses: ./.github/workflows/_reusable-docker-build-single.yml
secrets: inherit
with:
image_name: ${{ inputs.image_name }}
@@ -31,7 +31,7 @@ jobs:
registry_ghcr: ${{ inputs.registry_ghcr }}
release: ${{ inputs.release }}
build-server-arm64:
uses: ./.github/workflows/_reusable-docker-build-single.yaml
uses: ./.github/workflows/_reusable-docker-build-single.yml
secrets: inherit
with:
image_name: ${{ inputs.image_name }}

View File

@@ -256,7 +256,7 @@ jobs:
# Needed for checkout
contents: read
needs: ci-core-mark
uses: ./.github/workflows/_reusable-docker-build.yaml
uses: ./.github/workflows/_reusable-docker-build.yml
secrets: inherit
with:
image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}

25
.github/workflows/gh-cherry-pick.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: GH - Cherry-pick
on:
pull_request:
types: [closed, labeled]
pull_request_target:
types: [labeled]
jobs:
cherry-pick:
runs-on: ubuntu-latest
steps:
- id: app-token
name: Generate app token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: "${{ steps.app-token.outputs.token }}"
- uses: ./.github/actions/cherry-pick
with:
token: ${{ steps.app-token.outputs.token }}

View File

@@ -43,10 +43,13 @@ jobs:
with:
dependencies: python
- name: Create version branch
env:
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
run: |
current_major_version="$(uv version --short | grep -oE "^[0-9]{4}\.[0-9]{1,2}")"
git checkout -b "version-${current_major_version}"
git push origin "version-${current_major_version}"
gh label create "backport/version-${current_major_version}" --description "Add this label to PRs to backport changes to version-${current_major_version}" --color "fbca04"
bump-version-pr:
name: Open version bump PR
needs:

View File

@@ -7,7 +7,7 @@ on:
jobs:
build-server:
uses: ./.github/workflows/_reusable-docker-build.yaml
uses: ./.github/workflows/_reusable-docker-build.yml
secrets: inherit
permissions:
contents: read

View File

@@ -18,7 +18,17 @@ pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
redis_db := $(shell uv run python -m authentik.lib.config redis.db 2>/dev/null)
all: lint-fix lint test gen web ## Lint, build, and test everything
UNAME := $(shell uname)
## For macOS users, add the libxml2 and libxmlsec1 installed from brew to the build path
## to prevent SAML-related tests from failing and ensure correct compilation
ifeq ($(UNAME), Darwin)
BREW_LDFLAGS := -L$(shell brew --prefix libxml2)/lib $(LDFLAGS)
BREW_CPPFLAGS := -I$(shell brew --prefix libxml2)/include $(CPPFLAGS)
BREW_PKG_CONFIG_PATH := $(shell brew --prefix libxml2)/lib/pkgconfig:$(PKG_CONFIG_PATH)
endif
all: lint-fix lint gen web test ## Lint, build, and test everything
HELP_WIDTH := $(shell grep -h '^[a-z][^ ]*:.*\#\#' $(MAKEFILE_LIST) 2>/dev/null | \
cut -d':' -f1 | awk '{printf "%d\n", length}' | sort -rn | head -1)
@@ -50,7 +60,14 @@ lint: ## Lint the python and golang sources
golangci-lint run -v
core-install:
ifeq ($(UNAME), Darwin)
# Clear cache to ensure fresh compilation
uv cache clean
# Force compilation from source for lxml and xmlsec with correct environment
LDFLAGS="$(BREW_LDFLAGS)" CPPFLAGS="$(BREW_CPPFLAGS)" PKG_CONFIG_PATH="$(BREW_PKG_CONFIG_PATH)" uv sync --frozen --reinstall-package lxml --reinstall-package xmlsec --no-binary-package lxml --no-binary-package xmlsec
else
uv sync --frozen
endif
migrate: ## Run the Authentik Django server's migrations
uv run python -m lifecycle.migrate

2
web/.gitignore vendored
View File

@@ -25,6 +25,8 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
playwright-report
test-results
*.lcov
# nyc test coverage

View File

@@ -0,0 +1,175 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import type { LocatorContext } from "#e2e/selectors/types";
import { expect, Page } from "@playwright/test";
export class FormFixture extends PageFixture {
static fixtureName = "Form";
//#region Selector Methods
//#endregion
//#region Field Methods
/**
* Set the value of a text input.
*
* @param fieldName The name of the form element.
* @param value the value to set.
*/
public fill = async (
fieldName: string,
value: string,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent
.getByRole("textbox", {
name: fieldName,
})
.or(
parent.getByRole("spinbutton", {
name: fieldName,
}),
)
.first();
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
await control.fill(value);
};
/**
* Set the value of a radio or checkbox input.
*
* @param fieldName The name of the form element.
* @param value the value to set.
*/
public setInputCheck = async (
fieldName: string,
value: boolean = true,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent.locator("ak-switch-input", {
hasText: fieldName,
});
await control.scrollIntoViewIfNeeded();
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
const currentChecked = await control
.getAttribute("checked")
.then((value) => value !== null);
if (currentChecked === value) {
return;
}
await control.click();
};
/**
* Set the value of a radio or checkbox input.
*
* @param fieldName The name of the form element.
* @param pattern the value to set.
*/
public setRadio = async (
groupName: string,
fieldName: string,
parent: LocatorContext = this.page,
): Promise<void> => {
const group = parent.getByRole("group", { name: groupName });
await expect(group, `Field "${groupName}" should be visible`).toBeVisible();
const control = parent.getByRole("radio", { name: fieldName });
await control.setChecked(true, {
force: true,
});
};
/**
* Set the value of a search select input.
*
* @param fieldLabel The name of the search select element.
* @param pattern The text to match against the search select entry.
*/
public selectSearchValue = async (
fieldLabel: string,
pattern: string | RegExp,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent.getByRole("textbox", { name: fieldLabel });
await expect(
control,
`Search select control (${fieldLabel}) should be visible`,
).toBeVisible();
const fieldName = await control.getAttribute("name");
if (!fieldName) {
throw new Error(`Unable to find name attribute on search select (${fieldLabel})`);
}
// Find the search select input control and activate it.
await control.click();
const button = this.page
// ---
.locator(`div[data-managed-for*="${fieldName}"] button`, {
hasText: pattern,
});
if (!button) {
throw new Error(
`Unable to find an ak-search-select entry matching ${fieldLabel}:${pattern.toString()}`,
);
}
await button.click();
await this.page.keyboard.press("Tab");
await control.blur();
};
public setFormGroup = async (
pattern: string | RegExp,
value: boolean = true,
parent: LocatorContext = this.page,
) => {
const control = parent
.locator("ak-form-group", {
hasText: pattern,
})
.first();
const currentOpen = await control.getAttribute("open").then((value) => value !== null);
if (currentOpen === value) {
this.logger.debug(`Form group ${pattern} is already ${value ? "open" : "closed"}`);
return;
}
this.logger.debug(`Toggling form group ${pattern} to ${value ? "open" : "closed"}`);
await control.click();
if (value) {
await expect(control).toHaveAttribute("open");
} else {
await expect(control).not.toHaveAttribute("open");
}
};
//#endregion
//#region Lifecycle
constructor(page: Page, testName: string) {
super({ page, testName });
}
//#endregion
}

View File

@@ -0,0 +1,30 @@
import { ConsoleLogger, FixtureLogger } from "#logger/node";
import { Page } from "@playwright/test";
export interface PageFixtureOptions {
page: Page;
testName: string;
}
export abstract class PageFixture {
/**
* The name of the fixture.
*
* Used for logging.
*/
static fixtureName: string;
protected readonly logger: FixtureLogger;
protected readonly page: Page;
protected readonly testName: string;
constructor({ page, testName }: PageFixtureOptions) {
this.page = page;
this.testName = testName;
const Constructor = this.constructor as typeof PageFixture;
this.logger = ConsoleLogger.fixture(Constructor.fixtureName, this.testName);
}
}

View File

@@ -0,0 +1,42 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import type { LocatorContext } from "#e2e/selectors/types";
import { Page } from "@playwright/test";
export type GetByRoleParameters = Parameters<Page["getByRole"]>;
export type ARIARole = GetByRoleParameters[0];
export type ARIAOptions = GetByRoleParameters[1];
export type ClickByName = (name: string) => Promise<void>;
export type ClickByRole = (
role: ARIARole,
options?: ARIAOptions,
context?: LocatorContext,
) => Promise<void>;
export class PointerFixture extends PageFixture {
public static fixtureName = "Pointer";
public click = (
name: string,
optionsOrRole?: ARIAOptions | ARIARole,
context: LocatorContext = this.page,
): Promise<void> => {
if (typeof optionsOrRole === "string") {
return context.getByRole(optionsOrRole, { name }).click();
}
const options = {
...optionsOrRole,
name,
};
return (
context
// ---
.getByRole("button", options)
.or(context.getByRole("link", options))
.click()
);
};
}

View File

@@ -0,0 +1,104 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import { Page } from "@playwright/test";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
export const BAD_USERNAME = "bad-username@bad-login.io";
export const BAD_PASSWORD = "-this-is-a-bad-password-";
export interface LoginInit {
username?: string;
password?: string;
to?: URL | string;
}
export class SessionFixture extends PageFixture {
static fixtureName = "Session";
public static readonly pathname = "/if/flow/default-authentication-flow/";
//#region Selectors
public $identificationStage = this.page.locator("ak-stage-identification");
/**
* The username field on the login page.
*/
public $usernameField = this.page.getByLabel("Username");
public $passwordStage = this.page.locator("ak-stage-password");
public $passwordField = this.page.getByLabel("Password");
/**
* The button to submit the the login flow,
* typically redirecting to the authenticated interface.
*/
public $submitButton = this.page.locator('button[type="submit"]');
/**
* A possible authentication failure message.
*/
public $authFailureMessage = this.page.getByRole("alert", {
name: /(?:failed to authenticate)|(?:invalid password)/i,
});
//#endregion
constructor(page: Page, testName: string) {
super({ page, testName });
}
//#region Specific interactions
public checkAuthenticated = async (): Promise<boolean> => {
// TODO: Check if the user is authenticated via API
return true;
};
/**
* Log into the application.
*/
public async login({
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
}: LoginInit = {}) {
this.logger.info("Logging in...");
const initialURL = new URL(this.page.url());
if (initialURL.pathname === SessionFixture.pathname) {
this.logger.info("Skipping navigation because we're already in a authentication flow");
} else {
await this.page.goto(to.toString());
}
await this.$usernameField.fill(username);
const passwordFieldVisible = await this.$passwordField.isVisible();
if (!passwordFieldVisible) {
await this.$submitButton.click();
await this.$passwordField.waitFor({ state: "visible" });
}
await this.$passwordField.fill(password);
await this.$submitButton.click();
const expectedPathname = typeof to === "string" ? to : to.pathname;
await this.page.waitForURL(`**${expectedPathname}`);
}
//#endregion
//#region Navigation
public async toLoginPage() {
await this.page.goto(SessionFixture.pathname);
}
}

37
web/e2e/index.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* @file Playwright e2e test helpers.
*/
import { FormFixture } from "#e2e/fixtures/FormFixture";
import { PointerFixture } from "#e2e/fixtures/PointerFixture";
import { SessionFixture } from "#e2e/fixtures/SessionFixture";
import { test as base } from "@playwright/test";
export { expect } from "@playwright/test";
/* eslint-disable react-hooks/rules-of-hooks */
interface E2EFixturesTestScope {
session: SessionFixture;
pointer: PointerFixture;
form: FormFixture;
}
interface E2EWorkerScope {
selectorRegistration: void;
}
export const test = base.extend<E2EFixturesTestScope, E2EWorkerScope>({
session: async ({ page }, use, { title }) => {
await use(new SessionFixture(page, title));
},
form: async ({ page }, use, { title }) => {
await use(new FormFixture(page, title));
},
pointer: async ({ page }, use, { title }) => {
await use(new PointerFixture({ page, testName: title }));
},
});

View File

@@ -0,0 +1,13 @@
import type { Locator } from "@playwright/test";
export type LocatorContext = Pick<
Locator,
| "locator"
| "getByRole"
| "getByTestId"
| "getByText"
| "getByLabel"
| "getByAltText"
| "getByTitle"
| "getByPlaceholder"
>;

View File

@@ -0,0 +1,60 @@
import { IDGenerator } from "@goauthentik/core/id";
import {
adjectives,
colors,
Config as NameConfig,
uniqueNamesGenerator,
} from "unique-names-generator";
/**
* Given a dictionary of words, slice the dictionary to only include words that start with the given letter.
*/
export function alliterate(dictionary: string[], letter: string): string[] {
let firstIndex = 0;
for (let i = 0; i < dictionary.length; i++) {
if (dictionary[i][0] === letter) {
firstIndex = i;
break;
}
}
let lastIndex = firstIndex;
for (let i = firstIndex; i < dictionary.length; i++) {
if (dictionary[i][0] !== letter) {
lastIndex = i;
break;
}
}
return dictionary.slice(firstIndex, lastIndex);
}
export function createRandomName({
seed = IDGenerator.randomID(),
...config
}: Partial<NameConfig> = {}) {
const randomLetterIndex =
typeof seed === "number"
? seed
: Array.from(seed).reduce((acc, char) => acc + char.charCodeAt(0), 0);
const letter = adjectives[randomLetterIndex % adjectives.length][0];
const availableAdjectives = alliterate(adjectives, letter);
const availableColors = alliterate(colors, letter);
const name = uniqueNamesGenerator({
dictionaries: [availableAdjectives, availableAdjectives, availableColors],
style: "capital",
separator: " ",
length: 3,
seed,
...config,
});
return name;
}

102
web/logger/node.js Normal file
View File

@@ -0,0 +1,102 @@
/**
* Application logger.
*
* @import { LoggerOptions, Logger, Level, ChildLoggerOptions } from "pino"
* @import { PrettyOptions } from "pino-pretty"
*/
import { pino } from "pino";
//#region Constants
/**
* Default options for creating a Pino logger.
*
* @category Logger
* @satisfies {LoggerOptions<never, false>}
*/
export const DEFAULT_PINO_LOGGER_OPTIONS = {
enabled: true,
level: "info",
transport: {
target: "./transport.js",
options: /** @satisfies {PrettyOptions} */ ({
colorize: true,
}),
},
};
//#endregion
//#region Functions
/**
* Read the log level from the environment.
* @return {Level}
*/
export function readLogLevel() {
return process.env.AK_LOG_LEVEL || DEFAULT_PINO_LOGGER_OPTIONS.level;
}
/**
* @typedef {Logger} FixtureLogger
*/
/**
* @this {Logger}
* @param {string} fixtureName
* @param {string} [testName]
* @param {ChildLoggerOptions} [options]
* @returns {FixtureLogger}
*/
function createFixtureLogger(fixtureName, testName, options) {
return this.child(
{ name: fixtureName },
{
msgPrefix: `[${testName}] `,
...options,
},
);
}
/**
* @typedef {object} CustomLoggerMethods
* @property {typeof createFixtureLogger} fixture
*/
/**
* @typedef {Logger & CustomLoggerMethods} ConsoleLogger
*/
/**
* A singleton logger instance for Node.js.
*
* ```js
* import { ConsoleLogger } from "#logger/node";
*
* ConsoleLogger.info("Hello, world!");
* ```
*
* @runtime node
* @type {ConsoleLogger}
*/
export const ConsoleLogger = Object.assign(
pino({
...DEFAULT_PINO_LOGGER_OPTIONS,
level: readLogLevel(),
}),
{ fixture: createFixtureLogger },
);
/**
* @typedef {ReturnType<ConsoleLogger['child']>} ChildConsoleLogger
*/
//#region Aliases
export const info = ConsoleLogger.info.bind(ConsoleLogger);
export const debug = ConsoleLogger.debug.bind(ConsoleLogger);
export const warn = ConsoleLogger.warn.bind(ConsoleLogger);
export const error = ConsoleLogger.error.bind(ConsoleLogger);
//#endregion

22
web/logger/transport.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* @file Pretty transport for Pino
*
* @import { PrettyOptions } from "pino-pretty"
*/
import PinoPretty from "pino-pretty";
/**
* @param {PrettyOptions} options
*/
function prettyTransporter(options) {
const pretty = PinoPretty({
...options,
ignore: "pid,hostname",
translateTime: "SYS:HH:MM:ss",
});
return pretty;
}
export default prettyTransporter;

2003
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,9 @@
"storybook:build": "wireit",
"test": "wireit",
"test:e2e": "wireit",
"test:e2e:next": "playwright test",
"test:e2e:watch": "wireit",
"test:next": "vitest",
"test:watch": "wireit",
"tsc": "wireit",
"watch": "run-s build-locales esbuild:watch"
@@ -69,6 +71,9 @@
"#flow/*": "./src/flow/*.js",
"#locales/*": "./src/locales/*.js",
"#stories/*": "./src/stories/*.js",
"#tests/*": "./tests/*.js",
"#e2e": "./e2e/index.ts",
"#e2e/*": "./e2e/*.ts",
"#*/browser": {
"types": "./out/*/browser.d.ts",
"import": "./*/browser.js"
@@ -113,6 +118,7 @@
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.2.0",
"@patternfly/patternfly": "^4.224.2",
"@playwright/test": "^1.54.1",
"@sentry/browser": "^10.5.0",
"@spotlightjs/spotlight": "^3.0.2",
"@storybook/addon-docs": "^9.1.2",
@@ -128,6 +134,7 @@
"@types/react-dom": "^19.1.7",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"@vitest/browser": "^3.2.4",
"@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1",
"change-case": "^5.4.4",
@@ -158,6 +165,9 @@
"md-front-matter": "^1.0.4",
"mermaid": "^11.10.0",
"npm-run-all": "^4.1.5",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"playwright": "^1.54.1",
"prettier": "^3.6.2",
"pseudolocale": "^2.1.0",
"rapidoc": "^9.3.8",
@@ -178,7 +188,10 @@
"turnstile-types": "^1.2.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.40.0",
"unique-names-generator": "^4.7.1",
"unist-util-visit": "^5.0.0",
"vite": "^7.0.6",
"vitest": "^3.2.4",
"webcomponent-qr-code": "^1.3.0",
"wireit": "^0.14.12",
"yaml": "^2.8.1"
@@ -190,11 +203,12 @@
"@rollup/rollup-darwin-arm64": "^4.46.3",
"@rollup/rollup-linux-arm64-gnu": "^4.46.3",
"@rollup/rollup-linux-x64-gnu": "^4.46.3",
"@wdio/browser-runner": "^9.19.1",
"@wdio/cli": "^9.19.1",
"@wdio/spec-reporter": "^9.19.1",
"@wdio/browser-runner": "^9.19.2",
"@wdio/cli": "^9.19.2",
"@wdio/spec-reporter": "^9.19.2",
"@web/test-runner": "^0.20.2",
"chromedriver": "^139.0.1"
"chromedriver": "^139.0.1",
"p-iteration": "^1.1.8"
},
"wireit": {
"build": {
@@ -303,14 +317,14 @@
}
},
"test": {
"command": "wdio ./wdio.conf.ts --logLevel=warn",
"command": "wdio ./wdio.conf.mjs --logLevel=warn",
"env": {
"CI": "true",
"TS_NODE_PROJECT": "tsconfig.test.json"
}
},
"test:e2e": {
"command": "wdio run ./tests/wdio.conf.ts",
"command": "wdio run ./tests/wdio.conf.mjs",
"dependencies": [
"build"
],
@@ -320,7 +334,7 @@
}
},
"test:e2e:watch": {
"command": "wdio run ./tests/wdio.conf.ts",
"command": "wdio run ./tests/wdio.conf.mjs",
"dependencies": [
"build"
],
@@ -329,7 +343,7 @@
}
},
"test:watch": {
"command": "wdio run ./wdio.conf.ts",
"command": "wdio run ./wdio.conf.mjs",
"dependencies": [
"build"
],

View File

@@ -0,0 +1,18 @@
/**
* @file Load the contents of an environment file into `process.env`.
*/
import { MonoRepoRoot } from "#paths/node";
import { existsSync } from "node:fs";
import { join } from "node:path";
const envFilePath = join(MonoRepoRoot, ".env");
if (existsSync(envFilePath)) {
console.debug(`Loading environment from ${envFilePath}`);
try {
process.loadEnvFile(envFilePath);
} catch (error) {
console.warn(`Failed to load environment from ${envFilePath}:`, error);
}
}

View File

@@ -49,7 +49,10 @@ export function readGitBuildHash() {
export function readBuildIdentifier() {
const { GIT_BUILD_HASH } = process.env;
if (!GIT_BUILD_HASH) return AuthentikVersion;
if (!GIT_BUILD_HASH) {
console.warn("GIT_BUILD_HASH is not set, falling back to authentik version.");
return AuthentikVersion;
}
return [AuthentikVersion, GIT_BUILD_HASH].join("+");
}

94
web/playwright.config.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* @file Playwright configuration.
*
* @see https://playwright.dev/docs/test-configuration
*
* @import { LogFn, Logger } from "pino"
*/
import { ConsoleLogger } from "#logger/node";
import { defineConfig, devices } from "@playwright/test";
const CI = !!process.env.CI;
/**
* @type {Map<string, Logger>}
*/
const LoggerCache = new Map();
const baseURL = process.env.AK_TEST_RUNNER_PAGE_URL ?? "http://localhost:9000";
export default defineConfig({
testDir: "./test/browser",
fullyParallel: true,
forbidOnly: CI,
retries: CI ? 2 : 0,
workers: CI ? 1 : undefined,
reporter: CI
? "github"
: [
// ---
["list", { printSteps: true }],
["html", { open: "never" }],
],
use: {
testIdAttribute: "data-test-id",
baseURL,
trace: "on-first-retry",
launchOptions: {
logger: {
isEnabled() {
return true;
},
log: (name, severity, message, args) => {
let logger = LoggerCache.get(name);
if (!logger) {
logger = ConsoleLogger.child({
name: `Playwright ${name.toUpperCase()}`,
});
LoggerCache.set(name, logger);
}
/**
* @type {LogFn}
*/
let log;
switch (severity) {
case "verbose":
log = logger.debug;
break;
case "warning":
log = logger.warn;
break;
case "error":
log = logger.error;
break;
default:
log = logger.info;
break;
}
if (name === "api") {
log = logger.debug;
}
log.call(logger, message.toString(), args);
},
},
},
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
],
});

View File

@@ -1,3 +1,9 @@
/**
* @file ESBuild script for building the authentik web UI.
*/
import "@goauthentik/core/environment/load/node";
import * as fs from "node:fs/promises";
import * as path from "node:path";

View File

@@ -36,9 +36,7 @@ export class ProviderWizard extends AKElement {
providerTypes: TypeCreate[] = [];
@property({ attribute: false })
finalHandler: () => Promise<void> = () => {
return Promise.resolve();
};
public finalHandler?: () => Promise<void>;
@query("ak-wizard")
wizard?: Wizard;
@@ -56,9 +54,7 @@ export class ProviderWizard extends AKElement {
.steps=${["initial"]}
header=${msg("New provider")}
description=${msg("Create a new provider.")}
.finalHandler=${() => {
return this.finalHandler();
}}
.finalHandler=${this.finalHandler}
>
<ak-wizard-page-type-create
name="selectProviderType"
@@ -82,7 +78,15 @@ export class ProviderWizard extends AKElement {
</ak-wizard-page-form>
`;
})}
<button slot="trigger" class="pf-c-button pf-m-primary">${this.createText}</button>
<button
aria-label=${msg("New Provider")}
aria-description="${msg("Open the wizard to create a new provider.")}"
type="button"
slot="trigger"
class="pf-c-button pf-m-primary"
>
${msg("Create")}
</button>
</ak-wizard>
`;
}

View File

@@ -292,7 +292,7 @@ export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "au
* @todo Can this be handled with a Lit Mixin?
*/
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
const element = document.body.querySelector<T>("[data-ak-interface-root]");
const element = document.body.querySelector<T>("[data-test-id=interface-root]");
if (!element) {
throw new Error(

View File

@@ -23,7 +23,7 @@ function renderError(detail: string) {
return nothing;
}
return html`<p class="pf-c-form__helper-text pf-m-error" aria-live="polite">
return html`<p class="pf-c-form__helper-text pf-m-error" role="alert" aria-label=${detail}>
<span class="pf-c-form__helper-text-icon">
<i class="fas fa-exclamation-circle" aria-hidden="true"></i> </span
>${detail}

View File

@@ -1,67 +1,32 @@
import { AKElement } from "#elements/Base";
import { HorizontalLightComponent } from "#components/HorizontalLightComponent";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
@customElement("ak-file-input")
export class AkFileInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
export class AkFileInput extends HorizontalLightComponent<string> {
#inputRef = createRef<HTMLInputElement>();
get files(): Iterable<File> {
return this.#inputRef.value?.files || [];
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
/*
* The message to show next to the "current icon".
*
* @attr
*/
@property({ type: String })
current = msg("Currently set to:");
@property({ type: String })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
@query('input[type="file"]')
input!: HTMLInputElement;
get files() {
return this.input.files;
#inputListener(ev: InputEvent) {
this.value = (ev.target as HTMLInputElement).value;
}
render() {
const currentMsg =
this.value && this.current
? html` <p class="pf-c-form__helper-text">${this.current} ${this.value}</p> `
: nothing;
return html`<ak-form-element-horizontal
?required="${this.required}"
label=${this.label}
name=${this.name}
>
<input type="file" value="" class="pf-c-form-control" />
${currentMsg}
${this.help.trim() ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal>`;
public override renderControl() {
return html` <input
${ref(this.#inputRef)}
id=${ifDefined(this.fieldID)}
type="file"
@input=${this.#inputListener}
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${!!this.required}
/>`;
}
}

View File

@@ -8,26 +8,26 @@ import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-text-input")
export class AkTextInput extends HorizontalLightComponent<string> {
@property({ type: String, reflect: true })
value = "";
public value = "";
@property({ type: String })
autocomplete?: string;
public autocomplete?: AutoFill;
@property({ type: String })
placeholder?: string;
public placeholder?: string;
renderControl() {
const setValue = (ev: InputEvent) => {
this.value = (ev.target as HTMLInputElement).value;
};
#inputListener(ev: InputEvent) {
this.value = (ev.target as HTMLInputElement).value;
}
public override renderControl() {
const code = this.inputHint === "code";
return html` <input
type="text"
role="textbox"
id=${ifDefined(this.fieldID)}
@input=${setValue}
@input=${this.#inputListener}
value=${ifDefined(this.value)}
class="${classMap({
"pf-c-form-control": true,

View File

@@ -80,7 +80,7 @@ export abstract class WizardStep extends AKElement {
];
@property({ type: Boolean, attribute: true, reflect: true })
enabled = false;
public enabled?: boolean;
/**
* The name. Should match the slot. Reflected if not present.
@@ -89,7 +89,7 @@ export abstract class WizardStep extends AKElement {
name?: string;
@consume({ context: wizardStepContext, subscribe: true })
wizardStepState: WizardStepState = { currentStep: undefined, stepLabels: [] };
protected wizardStepState: WizardStepState = { currentStep: undefined, stepLabels: [] };
/**
* What appears in the titlebar of the Wizard. Usually, but not necessarily, the same for all
@@ -224,6 +224,7 @@ export abstract class WizardStep extends AKElement {
renderCloseButton(button: WizardButton) {
return html`<div class="pf-c-wizard__footer-cancel">
<button
data-test-id="wizard-navigation-abort"
class=${classMap(this.getButtonClasses(button))}
type="button"
@click=${this.onWizardCloseEvent}
@@ -262,7 +263,7 @@ export abstract class WizardStep extends AKElement {
});
}
renderHeaderCancelIcon() {
protected renderHeaderCancelIcon() {
return html`<button
class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button"
@@ -300,43 +301,58 @@ export abstract class WizardStep extends AKElement {
return this.wizardStepState.currentStep === this.getAttribute("slot")
? html` <div class="pf-c-modal-box ak-wizard-box">
<div class="pf-c-wizard">
<div class="pf-c-wizard__header" data-ouid-component-id="wizard-header">
<header class="pf-c-wizard__header" data-ouid-component-id="wizard-header">
${this.canCancel ? this.renderHeaderCancelIcon() : nothing}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">
<h1
class="pf-c-title pf-m-3xl pf-c-wizard__title"
data-test-id="wizard-title"
>
${this.wizardTitle}
</h1>
<p class="pf-c-wizard__description">${this.wizardDescription}</p>
</div>
</header>
<div class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap">
<nav class="pf-c-wizard__nav" data-ouid-component-id="wizard-navbar">
<aside
class="pf-c-wizard__nav"
role="group"
aria-label="${msg("Wizard steps")}"
>
<ol class="pf-c-wizard__nav-list">
${map(
this.wizardStepState.stepLabels,
this.renderSidebarStep,
)}
</ol>
</nav>
</aside>
<main class="pf-c-wizard__main">
<div
id="main-content"
class="pf-c-wizard__main-body"
data-ouid-component-id="wizard-body"
>
<div id="main-content" class="pf-c-wizard__main-body">
${this.renderMain()}
</div>
</main>
</div>
<footer
class="pf-c-wizard__footer"
data-ouid-component-id="wizard-footer"
>
<nav class="pf-c-wizard__footer" aria-label="${msg("Wizard navigation")}">
${this.buttons.map(this.renderButton)}
</footer>
</nav>
</div>
</div>
</div>`
: nothing;
}
}
declare global {
interface WizardNavigationTestIDMap {
abort: HTMLButtonElement;
}
interface WizardTestIDMap {
navigation: WizardNavigationTestIDMap;
title: HTMLHeadingElement;
}
interface TestIDSelectorMap {
wizard: WizardTestIDMap;
}
}

View File

@@ -1,12 +1,12 @@
import { NavigationEventInit, WizardNavigationEvent } from "./events.js";
import { WizardStepState } from "./types.js";
import { WizardStepLabel, WizardStepState } from "./types.js";
import { wizardStepContext } from "./WizardContexts.js";
import { type WizardStep } from "./WizardStep.js";
import { AKElement } from "#elements/Base";
import { bound } from "#elements/decorators/bound";
import { ContextProvider } from "@lit/context";
import { Context, ContextProvider } from "@lit/context";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -26,11 +26,11 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-wizard-steps")
export class WizardStepsManager extends AKElement {
@property({ type: String, attribute: true })
currentStep?: string;
public currentStep?: string;
wizardStepContext!: ContextProvider<{ __context__: WizardStepState | undefined }>;
protected wizardStepContext!: ContextProvider<Context<symbol, WizardStepState>>;
slots: WizardStep[] = [];
protected slots: WizardStep[] = [];
constructor() {
super();
@@ -57,13 +57,12 @@ export class WizardStepsManager extends AKElement {
return target;
}
get stepLabels() {
protected get stepLabels(): WizardStepLabel[] {
return this.slots
.filter((slot) => !slot.hide)
.map((slot) => ({
label: slot.label,
id: slot.slot,
active: true,
enabled: slot.enabled,
}));
}
@@ -77,14 +76,18 @@ export class WizardStepsManager extends AKElement {
connectedCallback() {
super.connectedCallback();
this.findSlots();
this.findStepLabels();
if (!this.currentStep && this.slots.length > 0) {
const currentStep = this.slots[0].getAttribute("slot");
if (!currentStep) {
throw new Error("All steps managed by this component must have a slot definition.");
}
this.currentStep = currentStep;
this.wizardStepContext.setValue({
stepLabels: this.stepLabels,
currentStep: currentStep,

View File

@@ -10,12 +10,11 @@ export type NavigableButton = Extract<WizardButton, { destination: string }>;
export type ButtonKind = Extract<WizardButton["kind"], PropertyKey>;
export type WizardStepLabel = {
export interface WizardStepLabel {
label: string;
id: string;
active: boolean;
enabled: boolean;
};
enabled?: boolean;
}
export type WizardStepState = {
currentStep?: string;

View File

@@ -28,6 +28,6 @@ export abstract class Interface extends AKElement {
public connectedCallback(): void {
super.connectedCallback();
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
this.dataset.testId = "interface-root";
}
}

View File

@@ -103,7 +103,7 @@ type ContentValue = SlottedTemplateResult | undefined;
*/
export function akLoadingOverlay(
properties: ILoadingOverlay = {},
content: ILoadingOverlayContent = {},
content: string | ILoadingOverlayContent = {},
) {
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
// slot-name.

View File

@@ -448,12 +448,16 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
}
return html`<div class="pf-c-form__alert">
${this.nonFieldErrors.map((err) => {
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
${this.nonFieldErrors.map((err, idx) => {
return html`<div
class="pf-c-alert pf-m-inline pf-m-danger"
role="alert"
aria-labelledby="error-message-${idx}"
>
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
<i aria-hidden="true" class="fas fa-exclamation-circle"></i>
</div>
<h4 class="pf-c-alert__title">${err}</h4>
<p id="error-message-${idx}" class="pf-c-alert__title">${err}</p>
</div>`;
})}
</div>`;

View File

@@ -8,6 +8,7 @@ import { TypeCreate } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { createRef, ref, Ref } from "lit/directives/ref.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
@@ -29,7 +30,7 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
types: TypeCreate[] = [];
@property({ attribute: false })
selectedType?: TypeCreate;
public selectedType: TypeCreate | null = null;
@property({ type: String })
layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list;
@@ -63,21 +64,22 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
public reset = () => {
super.reset();
this.selectedType = undefined;
this.selectedType = null;
this.formRef.value?.reset();
};
activeCallback = (): void => {
public override activeCallback = (): void => {
const form = this.formRef.value;
this.host.isValid = form?.checkValidity() ?? false;
if (this.selectedType) {
this.selectDispatch(this.selectedType);
this.#selectDispatch(this.selectedType);
}
};
private selectDispatch(type: TypeCreate) {
#selectDispatch = (type: TypeCreate) => {
this.dispatchEvent(
new CustomEvent("select", {
detail: type,
@@ -85,48 +87,56 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
composed: true,
}),
);
}
};
renderGrid(): TemplateResult {
protected renderGrid(): TemplateResult {
return html`<div
role="listbox"
aria-label="${msg("Select a provider type")}"
class="pf-l-grid pf-m-gutter"
data-ouid-component-type="ak-type-create-grid"
>
${this.types.map((type, idx) => {
const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
const disabled = !!(type.requiresEnterprise && !this.hasEnterpriseLicense);
const selected = this.selectedType === type;
// It's valid to pass in a local modelName or the full name with application
// part. If the latter, we only want the part after the dot to appear as our
// OUIA tag for test automation.
const componentName = type.modelName.includes(".")
? (type.modelName.split(".")[1] ?? "--unknown--")
: type.modelName;
return html`<div
class="pf-l-grid__item pf-m-3-col pf-c-card ${requiresEnterprise
? "pf-m-non-selectable-raised"
: "pf-m-selectable-raised"} ${this.selectedType === type
? "pf-m-selected-raised"
: ""}"
class=${classMap({
"pf-l-grid__item": true,
"pf-m-3-col": true,
"pf-c-card": true,
"pf-m-non-selectable-raised": disabled,
"pf-m-selectable-raised": !disabled,
"pf-m-selected-raised": selected,
})}
tabindex=${idx}
data-ouid-component-type="ak-type-create-grid-card"
data-ouid-component-name=${componentName}
role="option"
aria-disabled="${disabled ? "true" : "false"}"
aria-selected="${selected ? "true" : "false"}"
aria-label="${type.name}"
aria-describedby="${type.description}"
@click=${() => {
if (requiresEnterprise) return;
if (disabled) return;
this.selectDispatch(type);
this.#selectDispatch(type);
this.selectedType = type;
}}
>
${type.iconUrl
? html`<div class="pf-c-card__header">
<div class="pf-c-card__header-main">
<img src=${type.iconUrl} alt=${msg(str`${type.name} Icon`)} />
? html`<div role="presentation" class="pf-c-card__header">
<div role="presentation" class="pf-c-card__header-main">
<img
aria-hidden="true"
src=${type.iconUrl}
alt=${msg(str`${type.name} Icon`)}
/>
</div>
</div>`
: nothing}
<div class="pf-c-card__title">${type.name}</div>
<div class="pf-c-card__body">${type.description}</div>
${requiresEnterprise
<div role="heading" aria-level="2" class="pf-c-card__title">${type.name}</div>
<div role="presentational" class="pf-c-card__body">${type.description}</div>
${disabled
? html`<div class="pf-c-card__footer">
<ak-license-notice></ak-license-notice>
</div> `
@@ -140,34 +150,37 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
return html`<form
${ref(this.formRef)}
class="pf-c-form pf-m-horizontal"
data-ouid-component-type="ak-type-create-list"
role="radiogroup"
aria-label=${msg("Select a provider type")}
>
${this.types.map((type) => {
const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
const disabled = !!(type.requiresEnterprise && !this.hasEnterpriseLicense);
const inputID = `${type.component}-${type.modelName}`;
const selected = this.selectedType === type;
return html`<div
class="pf-c-radio"
data-ouid-component-type="ak-type-create-list-card"
data-ouid-component-name=${type.modelName.split(".")[1] ?? "--unknown--"}
>
return html`<div class="pf-c-radio">
<input
class="pf-c-radio__input"
type="radio"
name="type"
id=${`${type.component}-${type.modelName}`}
id=${`${inputID}`}
aria-label=${type.name}
aria-describedby=${`${inputID}-description`}
@change=${() => {
this.selectDispatch(type);
this.#selectDispatch(type);
}}
?disabled=${requiresEnterprise}
?disabled=${disabled}
/>
<label class="pf-c-radio__label" for=${`${type.component}-${type.modelName}`}
<label
aria-selected="${selected ? "true" : "false"}"
aria-labelledby="${inputID}"
class="pf-c-radio__label"
for="${inputID}"
>${type.name}</label
>
<span class="pf-c-radio__description"
<span id="${inputID}-description" class="pf-c-radio__description"
>${type.description}
${requiresEnterprise
? html`<ak-license-notice></ak-license-notice>`
: nothing}
${disabled ? html`<ak-license-notice></ak-license-notice>` : nothing}
</span>
</div>`;
})}

View File

@@ -40,13 +40,13 @@ export class Wizard extends ModalButton {
* Whether the wizard can be cancelled.
*/
@property({ type: Boolean })
canCancel = true;
public canCancel = true;
/**
* Whether the wizard can go back to the previous step.
*/
@property({ type: Boolean })
canBack = true;
public canBack = true;
/**
* Header title of the wizard.
@@ -64,7 +64,7 @@ export class Wizard extends ModalButton {
* Whether the wizard is valid and can proceed to the next step.
*/
@property({ type: Boolean })
isValid = false;
public isValid?: boolean;
/**
* Actions to display at the end of the wizard.
@@ -273,25 +273,42 @@ export class Wizard extends ModalButton {
this.activeStepElement = nextPage;
}
};
return html`<div class="pf-c-wizard">
<div class="pf-c-wizard__header">
return html`<div class="pf-c-wizard" role="presentation">
<header class="pf-c-wizard__header">
${this.canCancel
? html`<button
data-test-id="wizard-close"
class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button"
aria-label="${msg("Close")}"
aria-label="${msg("Close wizard")}"
@click=${this.#reset}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`
: nothing}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.header}</h1>
<p class="pf-c-wizard__description">${this.description}</p>
</div>
<div class="pf-c-wizard__outer-wrap">
<h1
id="modal-title"
role="heading"
aria-level="1"
class="pf-c-title pf-m-3xl pf-c-wizard__title"
data-test-id="wizard-heading"
>
${this.header}
</h1>
<p
role="heading"
aria-level="2"
id="modal-description"
class="pf-c-wizard__description"
>
${this.description}
</p>
</header>
<div role="presentation" class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap">
<nav class="pf-c-wizard__nav">
<ol class="pf-c-wizard__nav-list">
<nav aria-label="${msg("Wizard steps")}" class="pf-c-wizard__nav">
<ol role="presentation" class="pf-c-wizard__nav-list">
${this.steps.map((step, idx) => {
const stepEl = this.getStepElementByName(step);
@@ -300,7 +317,7 @@ export class Wizard extends ModalButton {
const sidebarLabel = stepEl.sidebarLabel();
return html`
<li class="pf-c-wizard__nav-item">
<li role="presentation" class="pf-c-wizard__nav-item">
<button
class=${classMap({
"pf-c-wizard__nav-link": true,
@@ -319,14 +336,15 @@ export class Wizard extends ModalButton {
})}
</ol>
</nav>
<main class="pf-c-wizard__main">
<div class="pf-c-wizard__main-body">
<main aria-label="${msg("Wizard content")}" class="pf-c-wizard__main">
<div role="presentation" class="pf-c-wizard__main-body">
<slot name=${this.activeStepElement?.slot || this.steps[0]}></slot>
</div>
</main>
</div>
<footer class="pf-c-wizard__footer">
<nav class="pf-c-wizard__footer" aria-label="${msg("Wizard navigation")}">
<button
data-test-id="wizard-navigation-next"
class="pf-c-button pf-m-primary"
?disabled=${!this.isValid}
type="button"
@@ -339,6 +357,7 @@ export class Wizard extends ModalButton {
: 0) > 0 && this.canBack
? html`
<button
data-test-id="wizard-navigation-previous"
class="pf-c-button pf-m-secondary"
type="button"
@click=${navigatePrevious}
@@ -348,8 +367,9 @@ export class Wizard extends ModalButton {
`
: nothing}
${this.canCancel
? html`<div class="pf-c-wizard__footer-cancel">
? html`<div class="pf-c-wizard__footer-abort">
<button
data-test-id="wizard-navigation-cancel"
class="pf-c-button pf-m-link"
type="button"
@click=${this.#reset}
@@ -358,7 +378,7 @@ export class Wizard extends ModalButton {
</button>
</div>`
: nothing}
</footer>
</nav>
</div>
</div>`;
}
@@ -370,4 +390,18 @@ declare global {
interface HTMLElementTagNameMap {
"ak-wizard": Wizard;
}
interface WizardNavigationTestIDMap {
next: HTMLButtonElement;
previous: HTMLButtonElement;
cancel: HTMLButtonElement;
}
interface WizardTestIDMap {
navigation: WizardNavigationTestIDMap;
}
interface TestIDSelectorMap {
wizard: WizardTestIDMap;
}
}

View File

@@ -97,12 +97,18 @@ export abstract class BaseStage<
}
return html`<div class="pf-c-form__alert">
${nonFieldErrors.map((err) => {
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
${nonFieldErrors.map((err, idx) => {
return html`<div
role="alert"
aria-labelledby="error-message-${idx}"
class="pf-c-alert pf-m-inline pf-m-danger"
>
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
<i aria-hidden="true" class="fas fa-exclamation-circle"></i>
</div>
<h4 class="pf-c-alert__title">${pluckErrorDetail(err)}</h4>
<p id="error-message-${idx}" class="pf-c-alert__title">
${pluckErrorDetail(err)}
</p>
</div>`;
})}
</div>`;

View File

@@ -1,802 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<meta name="color-scheme" content="light dark" />
<title>authentik</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 100%;
}
body {
font-size: clamp(16px, 3dvw, 20px);
background-color: #000;
background-color: Canvas;
color: #fff;
color: CanvasText;
font-family: system-ui, ui-sans-serif, sans-serif;
text-align: center;
display: grid;
place-content: center;
place-items: center;
min-height: 100dvh;
margin: 0;
padding-block: 0 6em;
padding-inline: 0.5em;
}
.logo {
height: 3.5em;
max-width: 90dvw;
}
.container {
position: absolute;
left: 0;
top: 50%;
right: 0;
bottom: 0;
z-index: -1;
overflow: hidden;
}
.container canvas {
display: block;
}
</style>
</head>
<body>
<svg
class="logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 144.29"
aria-label="authentik"
aria-role="heading"
aria-level="1"
preserveAspectRatio="xMidYMid meet"
>
<path
fill="currentColor"
d="M106 41.08h25.39v101.2H106v-10.7a50 50 0 0 1-14.92 10.19 41.84 41.84 0 0 1-16.21 3.11q-19.61 0-33.91-15.21T26.64 91.86q0-23.43 13.85-38.41t33.63-15a42.78 42.78 0 0 1 17.09 3.44A46.82 46.82 0 0 1 106 52.24ZM79.29 61.91a25.65 25.65 0 0 0-19.56 8.33q-7.78 8.33-7.79 21.34t7.93 21.58a25.66 25.66 0 0 0 19.51 8.47 26.15 26.15 0 0 0 19.84-8.33q7.88-8.33 7.88-21.81 0-13.2-7.88-21.39t-19.93-8.19ZM168.39 41.08h25.67v48.74q0 14.22 2 19.76a17.24 17.24 0 0 0 6.29 8.61 18.06 18.06 0 0 0 10.65 3.07 18.6 18.6 0 0 0 10.77-3 17.7 17.7 0 0 0 6.57-8.88q1.59-4.36 1.59-18.7v-49.6h25.39V84q0 26.51-4.18 36.27a39.6 39.6 0 0 1-15.07 18.28q-10 6.38-25.3 6.37-16.65 0-26.93-7.44t-14.48-20.78q-3-9.21-3-33.49ZM297.3 3.78h25.39v37.3h15.07v21.85h-15.07v79.35H297.3V62.93h-13V41.08h13ZM362.86 2h25.21v49.3a57.74 57.74 0 0 1 15-9.63 38.56 38.56 0 0 1 15.25-3.21 34.36 34.36 0 0 1 25.39 10.42q8.83 9 8.84 26.51v66.88h-25V97.91q0-17.58-1.68-23.81t-5.71-9.3a16.07 16.07 0 0 0-10-3.07 18.85 18.85 0 0 0-13.26 5.11q-5.53 5.11-7.67 14-1.12 4.56-1.12 20.84v40.65h-25.25ZM589.91 99h-81.58q1.77 10.78 9.44 17.16t19.58 6.37a33.86 33.86 0 0 0 24.46-10l21.4 10a50.54 50.54 0 0 1-19.16 16.79q-11.16 5.44-26.51 5.44-23.82 0-38.79-15t-15-37.63q0-23.16 14.93-38.46t37.44-15.3q23.91 0 38.88 15.3t15 40.42Zm-25.4-20a25.48 25.48 0 0 0-9.92-13.77A28.81 28.81 0 0 0 537.4 60a30.42 30.42 0 0 0-18.64 5.95q-5 3.72-9.31 13.12ZM621.89 41.08h25.39v10.37q8.64-7.29 15.65-10.13a37.82 37.82 0 0 1 14.35-2.85A34.77 34.77 0 0 1 702.83 49q8.82 8.94 8.82 26.42v66.88h-25.11V98q0-18.12-1.63-24.06a16.44 16.44 0 0 0-5.66-9.06 15.8 15.8 0 0 0-10-3.11 18.73 18.73 0 0 0-13.23 5.15q-5.49 5.08-7.62 14.22-1.12 4.74-1.12 20.54v40.6h-25.39ZM750.71 3.78h25.39v37.3h15.07v21.85H776.1v79.35h-25.39V62.93h-13V41.08h13ZM826.09-.6a15.55 15.55 0 0 1 11.45 4.84A16.08 16.08 0 0 1 842.31 16a15.87 15.87 0 0 1-4.72 11.58 15.34 15.34 0 0 1-11.32 4.79 15.6 15.6 0 0 1-11.55-4.88 16.35 16.35 0 0 1-4.72-11.9 15.57 15.57 0 0 1 4.73-11.44A15.53 15.53 0 0 1 826.09-.6ZM813.39 41.08h25.39v101.2h-25.39zM873.47 2h25.39v80.8l37.39-41.72h31.89l-43.59 48.5 48.81 52.7h-31.53l-43-46.64v46.64h-25.36Z"
/>
</svg>
<p>Server is starting up. Refreshing in a few seconds...</p>
<div class="container" aria-hidden="true">
<canvas id="waves-canvas"></canvas>
</div>
<script type="text/javascript">
"use strict";
let timeoutID = -1;
function checkStatus() {
console.log("Checking server status...");
return fetch(window.location.href, {
method: "HEAD",
headers: {
"content-type": "application/json",
},
})
.then((response) => {
if (response.ok) {
console.log("Server is ready! Reloading...");
clearTimeout(timeoutID);
window.location.reload();
return;
}
if (response.status === 503) {
throw new Error("Server is still starting up...");
}
})
.catch((error) => {
console.warn(`Checking server status failed: ${error}`);
timeoutID = setTimeout(checkStatus, 2000);
});
}
const queryParams = new URLSearchParams(window.location.search);
// if (window.location.origin.startsWith("http") && !queryParams.has("debug")) {
// console.log("Checking server status...");
// checkStatus();
// }
</script>
<script type="module">
/**
* @file Wave animation module.
*/
/**
* Data offsets (11 properties per particle)
*/
const ParticleOffsets = {
BASE_X: 0,
BASE_Z: 1,
BASE_Y: 2,
R: 3,
G: 4,
B: 5,
A: 6,
SIZE: 7,
PERSPECTIVE_SIZE: 8,
HALF_SIZE: 9,
PERSPECTIVE_DEPTH_ALPHA: 10,
PARTICLE_SIZE: 11,
};
/**
* @typedef {object} Particle
* @property {number} baseX
* @property {number} baseZ
* @property {number} baseY
* @property {number} x
* @property {number} y
* @property {number} r
* @property {number} g
* @property {number} b
* @property {number} a
* @property {number} size
* @property {number} perspectiveSize
* @property {number} halfSize
* @property {number} perspectiveDepthAlpha
*/
class WavesCanvas {
//#region Properties
width = 0;
height = 0;
dpi = Math.max(1, devicePixelRatio);
// Wave parameters
turbulence = Math.PI * 6.0;
pointSize = 1 * this.dpi;
pointSizeCutoff = 5;
speed = 10;
waveHeight = 5;
distance = 3;
fov = (60 * Math.PI) / 180;
aspectRatio = 1;
cameraZ = 95;
lodThreshold = this.cameraZ * 0.9;
/**
* Tangent of the field of view, i.e the angle between the horizon and the camera
*/
f = Math.tan(Math.PI * 0.5 - 0.5 * this.fov);
/**
* Flat array storing particle data: [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
* @type {Float32Array}
*/
#particleData = new Float32Array(0);
#particleOffsetCount = this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
// Performance optimizations
/**
* @type {ImageData}
*/
// @ts-expect-error - Assigned in resize listener
#buffer;
#gridWidth = 0;
#gridDepth = 0;
#fieldWidth = 0;
#fieldHeight = 0;
#fieldDepth = 0;
#relativeWidth = 0;
#relativeHeight = 0;
// Frustum culling bounds
#frustumLeft = 0;
#frustumRight = 0;
#frustumTop = 0;
#frustumBottom = 0;
#frustumNear = 0;
#frustumFar = 0;
/**
* @type {HTMLCanvasElement}
*/
#canvas;
/**
* @type {CanvasRenderingContext2D}
*/
#ctx;
//#endregion
//#region Lifecycle
/**
* @param {string | HTMLCanvasElement} target
*/
constructor(target) {
const element =
typeof target === "string" ? document.querySelector(target) : target;
if (!(element instanceof HTMLCanvasElement)) {
throw new TypeError("Invalid canvas element");
}
this.#canvas = element;
const ctx = this.#canvas.getContext("2d", {
alpha: false,
desynchronized: true,
});
if (!ctx) {
throw new TypeError("Failed to get 2D context");
}
this.#ctx = ctx;
// We apply a background color to the canvas element itself for a slight performance boost.
this.#canvas.style.background = getComputedStyle(document.body).backgroundColor;
// All containment rules are applied to the element to further improve performance.
this.#canvas.style.contain = "strict";
// Applying a null transform on the canvas forces the browser to use the GPU for rendering.
this.#canvas.style.willChange = "transform";
this.#canvas.style.transform = "translate3d(0, 0, 0)";
this.#canvas.style.pointerEvents = "none";
window.addEventListener("resize", this.refresh);
const colorSchemeChangeListener = window.matchMedia(
"(prefers-color-scheme: dark)",
);
colorSchemeChangeListener.addEventListener("change", this.refresh);
}
refresh = () => {
const container = this.#canvas.parentElement;
if (!container) return;
this.width = container.offsetWidth;
this.height = container.offsetHeight;
this.aspectRatio = this.width / this.height;
this.#relativeWidth = this.width * this.dpi;
this.#relativeHeight = this.height * this.dpi;
this.#canvas.width = this.#relativeWidth;
this.#canvas.height = this.#relativeHeight;
this.#canvas.style.width = this.width + "px";
this.#canvas.style.height = this.height + "px";
this.#ctx.scale(this.dpi, this.dpi);
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
this.#buffer = this.#ctx.createImageData(
this.width * this.dpi,
this.height * this.dpi,
);
this.#gridWidth = 300 * this.aspectRatio;
this.#gridDepth = 300;
this.#fieldWidth = this.#gridWidth;
this.#fieldHeight = this.waveHeight * this.aspectRatio;
this.#fieldDepth = this.#gridDepth;
this.#calculateFrustumBounds();
this.#generateParticles();
};
//#endregion
//#region Frustum Culling
#calculateFrustumBounds() {
const halfFov = this.fov / 2;
const tanHalfFov = Math.tan(halfFov);
// Calculate frustum bounds at different depths
this.#frustumNear = 1;
this.#frustumFar = this.cameraZ + this.#gridDepth;
// At near plane
const nearHeight = 2 * this.#frustumNear * tanHalfFov;
const nearWidth = nearHeight * this.aspectRatio;
// Store half-widths and half-heights for faster testing
this.#frustumLeft = -nearWidth / 2;
this.#frustumRight = nearWidth / 2;
this.#frustumTop = nearHeight / 2;
this.#frustumBottom = -nearHeight;
}
/**
* Test if a 3D point is within the view frustum
* @param {number} x - World X coordinate
* @param {number} y - World Y coordinate
* @param {number} z - World Z coordinate
* @returns {boolean}
*/
#isInFrustum(x, y, z) {
const projectedZ = z + this.cameraZ;
// Behind camera or too far
if (projectedZ <= this.#frustumNear || projectedZ > this.#frustumFar) {
return false;
}
// Calculate frustum bounds at this depth
const scale = projectedZ / this.#frustumNear;
const left = this.#frustumLeft * scale;
const right = this.#frustumRight * scale;
const top = this.#frustumTop * scale;
const bottom = this.#frustumBottom * scale;
// Test if point is within frustum bounds
return x >= left && x <= right && y >= bottom && y <= top;
}
//#endregion
//#region Particle Generation
#generateParticles() {
const particleCount =
Math.floor(this.#gridWidth / this.distance) *
Math.floor(this.#gridDepth / this.distance);
this.#particleData = new Float32Array(
particleCount * ParticleOffsets.PARTICLE_SIZE,
);
this.#particleOffsetCount =
this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
let particleIndex = 0;
for (let x = 0; x < this.#gridWidth; x += this.distance) {
const baseX = Math.round(-this.#gridWidth / 2 + x);
for (let z = 0; z < this.#gridDepth; z += this.distance) {
const baseZ = -this.#gridDepth / 2 + z;
const depthFactor = (z / this.#gridDepth) * 0.9;
const horizonFactor = (x / this.#gridWidth) * 1;
// Pre-calculate perspective size based on fixed depth
const projectedZ = baseZ + this.cameraZ;
const baseSize = (this.height / 250) * this.pointSize * this.dpi;
// More exaggerated scaling with logarithmic falloff for horizon effect
const normalizedDepth = Math.max(0, (projectedZ - 50) / 200);
// Log scale for dramatic falloff
const logScale = Math.log(1 + normalizedDepth * 4) / Math.log(6);
// Invert so closer = larger
const distanceFactor = Math.max(0.05, 1 - logScale);
const perspectiveSize = baseSize * distanceFactor * 3;
const alpha = Math.max(Math.floor(z / this.#gridDepth), 0.85);
const depthAlpha = Math.max(0.6, Math.pow(distanceFactor, 2));
const offset = particleIndex * ParticleOffsets.PARTICLE_SIZE;
// Store particle data in flat array
this.#particleData[offset + ParticleOffsets.BASE_X] = baseX;
this.#particleData[offset + ParticleOffsets.BASE_Z] = baseZ;
this.#particleData[offset + ParticleOffsets.BASE_Y] =
this.cameraZ * -0.4;
this.#particleData[offset + ParticleOffsets.R] = Math.min(
Math.floor(253 / (depthFactor * horizonFactor)),
255,
);
this.#particleData[offset + ParticleOffsets.G] = Math.min(
Math.floor(75 / horizonFactor),
255,
);
this.#particleData[offset + ParticleOffsets.B] = Math.min(
Math.floor(45 / (depthFactor * horizonFactor * 2)),
255,
);
this.#particleData[offset + ParticleOffsets.A] = alpha;
this.#particleData[offset + ParticleOffsets.SIZE] = baseSize;
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE] =
perspectiveSize;
this.#particleData[offset + ParticleOffsets.HALF_SIZE] = Math.max(
1,
Math.round(perspectiveSize / 1.5),
);
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA] =
depthAlpha ** 2;
particleIndex++;
}
}
}
//#endregion
//#region Projection
/**
* @param {number} x
* @param {number} y
* @param {number} z
* @returns {{x: number, y: number, z: number} | null}
*/
project3DTo2D(x, y, z) {
const projectedX = (x * this.f) / this.aspectRatio;
const projectedY = y * this.f;
const projectedZ = z + this.cameraZ;
if (projectedZ <= 0) return null;
// Convert to screen coordinates
// top of canvas is horizon (y=0), bottom is near
const screenX = ((projectedX / projectedZ) * this.width) / 2 + this.width / 2;
const screenY =
this.height / 2 -
((projectedY / projectedZ) * this.height) / 2 -
this.cameraZ * 2;
return { x: screenX, y: screenY, z: projectedZ };
}
//#endregion
//#region Rendering
/**
* @param {number} x
* @param {number} z
* @param {number} time
* @returns {number}
*/
calculateWaveY(x, z, time) {
return (
(Math.cos((x / this.#fieldWidth) * this.turbulence + time * this.speed) +
Math.sin(
(z / this.#fieldDepth) * this.turbulence + time * this.speed,
)) *
this.#fieldHeight
);
}
/**
* Draw a simple point particle (for distant objects)
* @param {number} x
* @param {number} y
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawPointParticle(x, y, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
if (centerX >= 0 && centerX < width && centerY >= 0 && centerY < height) {
const index = (centerY * width + centerX) * 4;
const alpha = Math.round(a * 255);
const blendFactor = alpha * 1.25;
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
data[index + 3] = 255;
}
}
/**
* Draw a hollow circle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawCircleParticle(x, y, halfSize, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
const radius = halfSize;
// Draw hollow circle - just the outline
for (let py = -radius; py <= radius; py++) {
for (let px = -radius; px <= radius; px++) {
const distance = Math.sqrt(px * px + py * py);
// Only draw pixels that are on the circle edge (within 1 pixel of radius)
if (distance >= radius - 1 && distance <= radius) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (
xPixel >= 0 &&
xPixel < width &&
yPixel >= 0 &&
yPixel < height
) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(
255,
data[index] + (r * blendFactor) / 255,
);
data[index + 1] = Math.min(
255,
data[index + 1] + (g * blendFactor) / 255,
);
data[index + 2] = Math.min(
255,
data[index + 2] + (b * blendFactor) / 255,
);
data[index + 3] = 255;
}
}
}
}
}
/**
* Draw a filled circle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawFilledCircleParticle(x, y, halfSize, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
const radius = halfSize;
// Draw solid circle
for (let py = -radius; py <= radius; py++) {
for (let px = -radius; px <= radius; px++) {
const distance = Math.sqrt(px * px + py * py);
// Draw all pixels within the radius
if (distance <= radius) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (
xPixel >= 0 &&
xPixel < width &&
yPixel >= 0 &&
yPixel < height
) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(
255,
data[index] + (r * blendFactor) / 255,
);
data[index + 1] = Math.min(
255,
data[index + 1] + (g * blendFactor) / 255,
);
data[index + 2] = Math.min(
255,
data[index + 2] + (b * blendFactor) / 255,
);
data[index + 3] = 255;
}
}
}
}
}
/**
* Draw an isosceles triangle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawTriangleParticle(x, y, halfSize, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
// Draw hollow isosceles triangle pointing up - just the outline
for (let py = -halfSize; py <= halfSize; py++) {
// Calculate max width at this height - steeper taper for more triangular shape
// 0 at top, 1 at bottom
const normalizedHeight = (py + halfSize) / (2 * halfSize);
const triangleWidth = Math.round(halfSize * normalizedHeight);
for (let px = -triangleWidth; px <= triangleWidth; px++) {
// Only draw if on the edge (left edge, right edge, or bottom edge)
const isLeftEdge = px === -triangleWidth;
const isRightEdge = px === triangleWidth;
const isBottomEdge = py === halfSize;
if (isLeftEdge || isRightEdge || isBottomEdge) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (
xPixel >= 0 &&
xPixel < width &&
yPixel >= 0 &&
yPixel < height
) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(
255,
data[index] + (r * blendFactor) / 255,
);
data[index + 1] = Math.min(
255,
data[index + 1] + (g * blendFactor) / 255,
);
data[index + 2] = Math.min(
255,
data[index + 2] + (b * blendFactor) / 255,
);
data[index + 3] = 255;
}
}
}
}
}
#render = (time = performance.now()) => {
const timestamp = time / 6000;
this.#buffer.data.fill(0);
for (let i = 0; i < this.#particleOffsetCount; i++) {
const offset = i * ParticleOffsets.PARTICLE_SIZE;
const baseX = this.#particleData[offset + ParticleOffsets.BASE_X];
const baseZ = this.#particleData[offset + ParticleOffsets.BASE_Z];
const baseY = this.#particleData[offset + ParticleOffsets.BASE_Y];
const waveY = this.calculateWaveY(baseX, baseZ, timestamp);
const worldY = baseY + waveY;
if (!this.#isInFrustum(baseX, worldY, baseZ)) {
continue;
}
const projected = this.project3DTo2D(baseX, worldY, baseZ);
if (
!projected ||
projected.x < -50 ||
projected.x > this.width + 50 ||
projected.y < 0 ||
projected.y > this.height + 50
) {
continue;
}
const distance = Math.abs(projected.z - this.cameraZ);
const perspectiveSize =
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE];
if (
distance > this.lodThreshold ||
perspectiveSize < this.pointSizeCutoff
) {
this.#drawPointParticle(
projected.x,
projected.y,
this.#particleData[offset + ParticleOffsets.R],
this.#particleData[offset + ParticleOffsets.G],
this.#particleData[offset + ParticleOffsets.B],
this.#particleData[
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
],
);
} else {
// Close enough for detailed triangle
this.#drawFilledCircleParticle(
projected.x,
projected.y,
this.#particleData[offset + ParticleOffsets.HALF_SIZE],
this.#particleData[offset + ParticleOffsets.R],
this.#particleData[offset + ParticleOffsets.G],
this.#particleData[offset + ParticleOffsets.B],
this.#particleData[
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
],
);
}
}
this.#ctx.putImageData(this.#buffer, 0, 0);
this.#ctx.globalCompositeOperation = "soft-light";
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
this.#ctx.globalCompositeOperation = "source-over";
this.#renderFrameID = requestAnimationFrame(this.#render);
};
//#endregion
//#region Public Methods
#renderFrameID = -1;
play = () => {
this.refresh();
this.#render();
};
pause = () => {
cancelAnimationFrame(this.#renderFrameID);
};
}
function drawAnimation() {
const canvas = document.getElementById("waves-canvas");
const waves = new WavesCanvas(canvas);
waves.play();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", drawAnimation);
} else {
drawAnimation();
}
</script>
</body>
</html>

View File

@@ -1,802 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<meta name="color-scheme" content="light dark" />
<title>authentik</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 100%;
}
body {
font-size: clamp(16px, 3dvw, 20px);
background-color: #000;
background-color: Canvas;
color: #fff;
color: CanvasText;
font-family: system-ui, ui-sans-serif, sans-serif;
text-align: center;
display: grid;
place-content: center;
place-items: center;
min-height: 100dvh;
margin: 0;
padding-block: 0 6em;
padding-inline: 0.5em;
}
.logo {
height: 3.5em;
max-width: 90dvw;
}
.container {
position: absolute;
left: 0;
top: 50%;
right: 0;
bottom: 0;
z-index: -1;
overflow: hidden;
}
.container canvas {
display: block;
}
</style>
</head>
<body>
<svg
class="logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 144.29"
aria-label="authentik"
aria-role="heading"
aria-level="1"
preserveAspectRatio="xMidYMid meet"
>
<path
fill="currentColor"
d="M106 41.08h25.39v101.2H106v-10.7a50 50 0 0 1-14.92 10.19 41.84 41.84 0 0 1-16.21 3.11q-19.61 0-33.91-15.21T26.64 91.86q0-23.43 13.85-38.41t33.63-15a42.78 42.78 0 0 1 17.09 3.44A46.82 46.82 0 0 1 106 52.24ZM79.29 61.91a25.65 25.65 0 0 0-19.56 8.33q-7.78 8.33-7.79 21.34t7.93 21.58a25.66 25.66 0 0 0 19.51 8.47 26.15 26.15 0 0 0 19.84-8.33q7.88-8.33 7.88-21.81 0-13.2-7.88-21.39t-19.93-8.19ZM168.39 41.08h25.67v48.74q0 14.22 2 19.76a17.24 17.24 0 0 0 6.29 8.61 18.06 18.06 0 0 0 10.65 3.07 18.6 18.6 0 0 0 10.77-3 17.7 17.7 0 0 0 6.57-8.88q1.59-4.36 1.59-18.7v-49.6h25.39V84q0 26.51-4.18 36.27a39.6 39.6 0 0 1-15.07 18.28q-10 6.38-25.3 6.37-16.65 0-26.93-7.44t-14.48-20.78q-3-9.21-3-33.49ZM297.3 3.78h25.39v37.3h15.07v21.85h-15.07v79.35H297.3V62.93h-13V41.08h13ZM362.86 2h25.21v49.3a57.74 57.74 0 0 1 15-9.63 38.56 38.56 0 0 1 15.25-3.21 34.36 34.36 0 0 1 25.39 10.42q8.83 9 8.84 26.51v66.88h-25V97.91q0-17.58-1.68-23.81t-5.71-9.3a16.07 16.07 0 0 0-10-3.07 18.85 18.85 0 0 0-13.26 5.11q-5.53 5.11-7.67 14-1.12 4.56-1.12 20.84v40.65h-25.25ZM589.91 99h-81.58q1.77 10.78 9.44 17.16t19.58 6.37a33.86 33.86 0 0 0 24.46-10l21.4 10a50.54 50.54 0 0 1-19.16 16.79q-11.16 5.44-26.51 5.44-23.82 0-38.79-15t-15-37.63q0-23.16 14.93-38.46t37.44-15.3q23.91 0 38.88 15.3t15 40.42Zm-25.4-20a25.48 25.48 0 0 0-9.92-13.77A28.81 28.81 0 0 0 537.4 60a30.42 30.42 0 0 0-18.64 5.95q-5 3.72-9.31 13.12ZM621.89 41.08h25.39v10.37q8.64-7.29 15.65-10.13a37.82 37.82 0 0 1 14.35-2.85A34.77 34.77 0 0 1 702.83 49q8.82 8.94 8.82 26.42v66.88h-25.11V98q0-18.12-1.63-24.06a16.44 16.44 0 0 0-5.66-9.06 15.8 15.8 0 0 0-10-3.11 18.73 18.73 0 0 0-13.23 5.15q-5.49 5.08-7.62 14.22-1.12 4.74-1.12 20.54v40.6h-25.39ZM750.71 3.78h25.39v37.3h15.07v21.85H776.1v79.35h-25.39V62.93h-13V41.08h13ZM826.09-.6a15.55 15.55 0 0 1 11.45 4.84A16.08 16.08 0 0 1 842.31 16a15.87 15.87 0 0 1-4.72 11.58 15.34 15.34 0 0 1-11.32 4.79 15.6 15.6 0 0 1-11.55-4.88 16.35 16.35 0 0 1-4.72-11.9 15.57 15.57 0 0 1 4.73-11.44A15.53 15.53 0 0 1 826.09-.6ZM813.39 41.08h25.39v101.2h-25.39zM873.47 2h25.39v80.8l37.39-41.72h31.89l-43.59 48.5 48.81 52.7h-31.53l-43-46.64v46.64h-25.36Z"
/>
</svg>
<p>Server is starting up. Refreshing in a few seconds...</p>
<div class="container" aria-hidden="true">
<canvas id="waves-canvas"></canvas>
</div>
<script type="text/javascript">
"use strict";
let timeoutID = -1;
function checkStatus() {
console.log("Checking server status...");
return fetch(window.location.href, {
method: "HEAD",
headers: {
"content-type": "application/json",
},
})
.then((response) => {
if (response.ok) {
console.log("Server is ready! Reloading...");
clearTimeout(timeoutID);
window.location.reload();
return;
}
if (response.status === 503) {
throw new Error("Server is still starting up...");
}
})
.catch((error) => {
console.warn(`Checking server status failed: ${error}`);
timeoutID = setTimeout(checkStatus, 2000);
});
}
const queryParams = new URLSearchParams(window.location.search);
// if (window.location.origin.startsWith("http") && !queryParams.has("debug")) {
// console.log("Checking server status...");
// checkStatus();
// }
</script>
<script type="module">
/**
* @file Wave animation module.
*/
/**
* Data offsets (11 properties per particle)
*/
const ParticleOffsets = {
BASE_X: 0,
BASE_Z: 1,
BASE_Y: 2,
R: 3,
G: 4,
B: 5,
A: 6,
SIZE: 7,
PERSPECTIVE_SIZE: 8,
HALF_SIZE: 9,
PERSPECTIVE_DEPTH_ALPHA: 10,
PARTICLE_SIZE: 11,
};
/**
* @typedef {object} Particle
* @property {number} baseX
* @property {number} baseZ
* @property {number} baseY
* @property {number} x
* @property {number} y
* @property {number} r
* @property {number} g
* @property {number} b
* @property {number} a
* @property {number} size
* @property {number} perspectiveSize
* @property {number} halfSize
* @property {number} perspectiveDepthAlpha
*/
class WavesCanvas {
//#region Properties
width = 0;
height = 0;
dpi = Math.max(1, devicePixelRatio);
// Wave parameters
turbulence = Math.PI * 6.0;
pointSize = 1 * this.dpi;
pointSizeCutoff = 5;
speed = 10;
waveHeight = 5;
distance = 3;
fov = (60 * Math.PI) / 180;
aspectRatio = 1;
cameraZ = 95;
lodThreshold = this.cameraZ * 0.9;
/**
* Tangent of the field of view, i.e the angle between the horizon and the camera
*/
f = Math.tan(Math.PI * 0.5 - 0.5 * this.fov);
/**
* Flat array storing particle data: [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
* @type {Float32Array}
*/
#particleData = new Float32Array(0);
#particleOffsetCount = this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
// Performance optimizations
/**
* @type {ImageData}
*/
// @ts-expect-error - Assigned in resize listener
#buffer;
#gridWidth = 0;
#gridDepth = 0;
#fieldWidth = 0;
#fieldHeight = 0;
#fieldDepth = 0;
#relativeWidth = 0;
#relativeHeight = 0;
// Frustum culling bounds
#frustumLeft = 0;
#frustumRight = 0;
#frustumTop = 0;
#frustumBottom = 0;
#frustumNear = 0;
#frustumFar = 0;
/**
* @type {HTMLCanvasElement}
*/
#canvas;
/**
* @type {CanvasRenderingContext2D}
*/
#ctx;
//#endregion
//#region Lifecycle
/**
* @param {string | HTMLCanvasElement} target
*/
constructor(target) {
const element =
typeof target === "string" ? document.querySelector(target) : target;
if (!(element instanceof HTMLCanvasElement)) {
throw new TypeError("Invalid canvas element");
}
this.#canvas = element;
const ctx = this.#canvas.getContext("2d", {
alpha: false,
desynchronized: true,
});
if (!ctx) {
throw new TypeError("Failed to get 2D context");
}
this.#ctx = ctx;
// We apply a background color to the canvas element itself for a slight performance boost.
this.#canvas.style.background = getComputedStyle(document.body).backgroundColor;
// All containment rules are applied to the element to further improve performance.
this.#canvas.style.contain = "strict";
// Applying a null transform on the canvas forces the browser to use the GPU for rendering.
this.#canvas.style.willChange = "transform";
this.#canvas.style.transform = "translate3d(0, 0, 0)";
this.#canvas.style.pointerEvents = "none";
window.addEventListener("resize", this.refresh);
const colorSchemeChangeListener = window.matchMedia(
"(prefers-color-scheme: dark)",
);
colorSchemeChangeListener.addEventListener("change", this.refresh);
}
refresh = () => {
const container = this.#canvas.parentElement;
if (!container) return;
this.width = container.offsetWidth;
this.height = container.offsetHeight;
this.aspectRatio = this.width / this.height;
this.#relativeWidth = this.width * this.dpi;
this.#relativeHeight = this.height * this.dpi;
this.#canvas.width = this.#relativeWidth;
this.#canvas.height = this.#relativeHeight;
this.#canvas.style.width = this.width + "px";
this.#canvas.style.height = this.height + "px";
this.#ctx.scale(this.dpi, this.dpi);
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
this.#buffer = this.#ctx.createImageData(
this.width * this.dpi,
this.height * this.dpi,
);
this.#gridWidth = 300 * this.aspectRatio;
this.#gridDepth = 300;
this.#fieldWidth = this.#gridWidth;
this.#fieldHeight = this.waveHeight * this.aspectRatio;
this.#fieldDepth = this.#gridDepth;
this.#calculateFrustumBounds();
this.#generateParticles();
};
//#endregion
//#region Frustum Culling
#calculateFrustumBounds() {
const halfFov = this.fov / 2;
const tanHalfFov = Math.tan(halfFov);
// Calculate frustum bounds at different depths
this.#frustumNear = 1;
this.#frustumFar = this.cameraZ + this.#gridDepth;
// At near plane
const nearHeight = 2 * this.#frustumNear * tanHalfFov;
const nearWidth = nearHeight * this.aspectRatio;
// Store half-widths and half-heights for faster testing
this.#frustumLeft = -nearWidth / 2;
this.#frustumRight = nearWidth / 2;
this.#frustumTop = nearHeight / 2;
this.#frustumBottom = -nearHeight;
}
/**
* Test if a 3D point is within the view frustum
* @param {number} x - World X coordinate
* @param {number} y - World Y coordinate
* @param {number} z - World Z coordinate
* @returns {boolean}
*/
#isInFrustum(x, y, z) {
const projectedZ = z + this.cameraZ;
// Behind camera or too far
if (projectedZ <= this.#frustumNear || projectedZ > this.#frustumFar) {
return false;
}
// Calculate frustum bounds at this depth
const scale = projectedZ / this.#frustumNear;
const left = this.#frustumLeft * scale;
const right = this.#frustumRight * scale;
const top = this.#frustumTop * scale;
const bottom = this.#frustumBottom * scale;
// Test if point is within frustum bounds
return x >= left && x <= right && y >= bottom && y <= top;
}
//#endregion
//#region Particle Generation
#generateParticles() {
const particleCount =
Math.floor(this.#gridWidth / this.distance) *
Math.floor(this.#gridDepth / this.distance);
this.#particleData = new Float32Array(
particleCount * ParticleOffsets.PARTICLE_SIZE,
);
this.#particleOffsetCount =
this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
let particleIndex = 0;
for (let x = 0; x < this.#gridWidth; x += this.distance) {
const baseX = Math.round(-this.#gridWidth / 2 + x);
for (let z = 0; z < this.#gridDepth; z += this.distance) {
const baseZ = -this.#gridDepth / 2 + z;
const depthFactor = (z / this.#gridDepth) * 0.9;
const horizonFactor = (x / this.#gridWidth) * 1;
// Pre-calculate perspective size based on fixed depth
const projectedZ = baseZ + this.cameraZ;
const baseSize = (this.height / 250) * this.pointSize * this.dpi;
// More exaggerated scaling with logarithmic falloff for horizon effect
const normalizedDepth = Math.max(0, (projectedZ - 50) / 200);
// Log scale for dramatic falloff
const logScale = Math.log(1 + normalizedDepth * 4) / Math.log(6);
// Invert so closer = larger
const distanceFactor = Math.max(0.05, 1 - logScale);
const perspectiveSize = baseSize * distanceFactor * 3;
const alpha = Math.max(Math.floor(z / this.#gridDepth), 0.85);
const depthAlpha = Math.max(0.6, Math.pow(distanceFactor, 2));
const offset = particleIndex * ParticleOffsets.PARTICLE_SIZE;
// Store particle data in flat array
this.#particleData[offset + ParticleOffsets.BASE_X] = baseX;
this.#particleData[offset + ParticleOffsets.BASE_Z] = baseZ;
this.#particleData[offset + ParticleOffsets.BASE_Y] =
this.cameraZ * -0.4;
this.#particleData[offset + ParticleOffsets.R] = Math.min(
Math.floor(253 / (depthFactor * horizonFactor)),
255,
);
this.#particleData[offset + ParticleOffsets.G] = Math.min(
Math.floor(75 / horizonFactor),
255,
);
this.#particleData[offset + ParticleOffsets.B] = Math.min(
Math.floor(45 / (depthFactor * horizonFactor * 2)),
255,
);
this.#particleData[offset + ParticleOffsets.A] = alpha;
this.#particleData[offset + ParticleOffsets.SIZE] = baseSize;
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE] =
perspectiveSize;
this.#particleData[offset + ParticleOffsets.HALF_SIZE] = Math.max(
1,
Math.round(perspectiveSize / 1.5),
);
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA] =
depthAlpha ** 2;
particleIndex++;
}
}
}
//#endregion
//#region Projection
/**
* @param {number} x
* @param {number} y
* @param {number} z
* @returns {{x: number, y: number, z: number} | null}
*/
project3DTo2D(x, y, z) {
const projectedX = (x * this.f) / this.aspectRatio;
const projectedY = y * this.f;
const projectedZ = z + this.cameraZ;
if (projectedZ <= 0) return null;
// Convert to screen coordinates
// top of canvas is horizon (y=0), bottom is near
const screenX = ((projectedX / projectedZ) * this.width) / 2 + this.width / 2;
const screenY =
this.height / 2 -
((projectedY / projectedZ) * this.height) / 2 -
this.cameraZ * 2;
return { x: screenX, y: screenY, z: projectedZ };
}
//#endregion
//#region Rendering
/**
* @param {number} x
* @param {number} z
* @param {number} time
* @returns {number}
*/
calculateWaveY(x, z, time) {
return (
(Math.cos((x / this.#fieldWidth) * this.turbulence + time * this.speed) +
Math.sin(
(z / this.#fieldDepth) * this.turbulence + time * this.speed,
)) *
this.#fieldHeight
);
}
/**
* Draw a simple point particle (for distant objects)
* @param {number} x
* @param {number} y
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawPointParticle(x, y, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
if (centerX >= 0 && centerX < width && centerY >= 0 && centerY < height) {
const index = (centerY * width + centerX) * 4;
const alpha = Math.round(a * 255);
const blendFactor = alpha * 1.25;
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
data[index + 3] = 255;
}
}
/**
* Draw a hollow circle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawCircleParticle(x, y, halfSize, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
const radius = halfSize;
// Draw hollow circle - just the outline
for (let py = -radius; py <= radius; py++) {
for (let px = -radius; px <= radius; px++) {
const distance = Math.sqrt(px * px + py * py);
// Only draw pixels that are on the circle edge (within 1 pixel of radius)
if (distance >= radius - 1 && distance <= radius) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (
xPixel >= 0 &&
xPixel < width &&
yPixel >= 0 &&
yPixel < height
) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(
255,
data[index] + (r * blendFactor) / 255,
);
data[index + 1] = Math.min(
255,
data[index + 1] + (g * blendFactor) / 255,
);
data[index + 2] = Math.min(
255,
data[index + 2] + (b * blendFactor) / 255,
);
data[index + 3] = 255;
}
}
}
}
}
/**
* Draw a filled circle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawFilledCircleParticle(x, y, halfSize, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
const radius = halfSize;
// Draw solid circle
for (let py = -radius; py <= radius; py++) {
for (let px = -radius; px <= radius; px++) {
const distance = Math.sqrt(px * px + py * py);
// Draw all pixels within the radius
if (distance <= radius) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (
xPixel >= 0 &&
xPixel < width &&
yPixel >= 0 &&
yPixel < height
) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(
255,
data[index] + (r * blendFactor) / 255,
);
data[index + 1] = Math.min(
255,
data[index + 1] + (g * blendFactor) / 255,
);
data[index + 2] = Math.min(
255,
data[index + 2] + (b * blendFactor) / 255,
);
data[index + 3] = 255;
}
}
}
}
}
/**
* Draw an isosceles triangle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawTriangleParticle(x, y, halfSize, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
// Draw hollow isosceles triangle pointing up - just the outline
for (let py = -halfSize; py <= halfSize; py++) {
// Calculate max width at this height - steeper taper for more triangular shape
// 0 at top, 1 at bottom
const normalizedHeight = (py + halfSize) / (2 * halfSize);
const triangleWidth = Math.round(halfSize * normalizedHeight);
for (let px = -triangleWidth; px <= triangleWidth; px++) {
// Only draw if on the edge (left edge, right edge, or bottom edge)
const isLeftEdge = px === -triangleWidth;
const isRightEdge = px === triangleWidth;
const isBottomEdge = py === halfSize;
if (isLeftEdge || isRightEdge || isBottomEdge) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (
xPixel >= 0 &&
xPixel < width &&
yPixel >= 0 &&
yPixel < height
) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(
255,
data[index] + (r * blendFactor) / 255,
);
data[index + 1] = Math.min(
255,
data[index + 1] + (g * blendFactor) / 255,
);
data[index + 2] = Math.min(
255,
data[index + 2] + (b * blendFactor) / 255,
);
data[index + 3] = 255;
}
}
}
}
}
#render = (time = performance.now()) => {
const timestamp = time / 6000;
this.#buffer.data.fill(0);
for (let i = 0; i < this.#particleOffsetCount; i++) {
const offset = i * ParticleOffsets.PARTICLE_SIZE;
const baseX = this.#particleData[offset + ParticleOffsets.BASE_X];
const baseZ = this.#particleData[offset + ParticleOffsets.BASE_Z];
const baseY = this.#particleData[offset + ParticleOffsets.BASE_Y];
const waveY = this.calculateWaveY(baseX, baseZ, timestamp);
const worldY = baseY + waveY;
if (!this.#isInFrustum(baseX, worldY, baseZ)) {
continue;
}
const projected = this.project3DTo2D(baseX, worldY, baseZ);
if (
!projected ||
projected.x < -50 ||
projected.x > this.width + 50 ||
projected.y < 0 ||
projected.y > this.height + 50
) {
continue;
}
const distance = Math.abs(projected.z - this.cameraZ);
const perspectiveSize =
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE];
if (
distance > this.lodThreshold ||
perspectiveSize < this.pointSizeCutoff
) {
this.#drawPointParticle(
projected.x,
projected.y,
this.#particleData[offset + ParticleOffsets.R],
this.#particleData[offset + ParticleOffsets.G],
this.#particleData[offset + ParticleOffsets.B],
this.#particleData[
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
],
);
} else {
// Close enough for detailed triangle
this.#drawCircleParticle(
projected.x,
projected.y,
this.#particleData[offset + ParticleOffsets.HALF_SIZE],
this.#particleData[offset + ParticleOffsets.R],
this.#particleData[offset + ParticleOffsets.G],
this.#particleData[offset + ParticleOffsets.B],
this.#particleData[
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
],
);
}
}
this.#ctx.putImageData(this.#buffer, 0, 0);
this.#ctx.globalCompositeOperation = "soft-light";
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
this.#ctx.globalCompositeOperation = "source-over";
this.#renderFrameID = requestAnimationFrame(this.#render);
};
//#endregion
//#region Public Methods
#renderFrameID = -1;
play = () => {
this.refresh();
this.#render();
};
pause = () => {
cancelAnimationFrame(this.#renderFrameID);
};
}
function drawAnimation() {
const canvas = document.getElementById("waves-canvas");
const waves = new WavesCanvas(canvas);
waves.play();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", drawAnimation);
} else {
drawAnimation();
}
</script>
</body>
</html>

View File

@@ -1,750 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<meta name="color-scheme" content="light dark" />
<title>authentik</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 100%;
}
body {
font-size: clamp(16px, 3dvw, 20px);
background-color: #000;
background-color: Canvas;
color: #fff;
color: CanvasText;
font-family: system-ui, ui-sans-serif, sans-serif;
text-align: center;
display: grid;
place-content: center;
place-items: center;
min-height: 100dvh;
margin: 0;
padding-block: 0 6em;
padding-inline: 0.5em;
}
.logo {
height: 3.5em;
max-width: 90dvw;
}
.container {
position: absolute;
left: 0;
top: 50%;
right: 0;
bottom: 0;
z-index: -1;
overflow: hidden;
}
.container canvas {
display: block;
}
</style>
</head>
<body>
<svg
class="logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 144.29"
aria-label="authentik"
aria-role="heading"
aria-level="1"
preserveAspectRatio="xMidYMid meet"
>
<path
fill="currentColor"
d="M106 41.08h25.39v101.2H106v-10.7a50 50 0 0 1-14.92 10.19 41.84 41.84 0 0 1-16.21 3.11q-19.61 0-33.91-15.21T26.64 91.86q0-23.43 13.85-38.41t33.63-15a42.78 42.78 0 0 1 17.09 3.44A46.82 46.82 0 0 1 106 52.24ZM79.29 61.91a25.65 25.65 0 0 0-19.56 8.33q-7.78 8.33-7.79 21.34t7.93 21.58a25.66 25.66 0 0 0 19.51 8.47 26.15 26.15 0 0 0 19.84-8.33q7.88-8.33 7.88-21.81 0-13.2-7.88-21.39t-19.93-8.19ZM168.39 41.08h25.67v48.74q0 14.22 2 19.76a17.24 17.24 0 0 0 6.29 8.61 18.06 18.06 0 0 0 10.65 3.07 18.6 18.6 0 0 0 10.77-3 17.7 17.7 0 0 0 6.57-8.88q1.59-4.36 1.59-18.7v-49.6h25.39V84q0 26.51-4.18 36.27a39.6 39.6 0 0 1-15.07 18.28q-10 6.38-25.3 6.37-16.65 0-26.93-7.44t-14.48-20.78q-3-9.21-3-33.49ZM297.3 3.78h25.39v37.3h15.07v21.85h-15.07v79.35H297.3V62.93h-13V41.08h13ZM362.86 2h25.21v49.3a57.74 57.74 0 0 1 15-9.63 38.56 38.56 0 0 1 15.25-3.21 34.36 34.36 0 0 1 25.39 10.42q8.83 9 8.84 26.51v66.88h-25V97.91q0-17.58-1.68-23.81t-5.71-9.3a16.07 16.07 0 0 0-10-3.07 18.85 18.85 0 0 0-13.26 5.11q-5.53 5.11-7.67 14-1.12 4.56-1.12 20.84v40.65h-25.25ZM589.91 99h-81.58q1.77 10.78 9.44 17.16t19.58 6.37a33.86 33.86 0 0 0 24.46-10l21.4 10a50.54 50.54 0 0 1-19.16 16.79q-11.16 5.44-26.51 5.44-23.82 0-38.79-15t-15-37.63q0-23.16 14.93-38.46t37.44-15.3q23.91 0 38.88 15.3t15 40.42Zm-25.4-20a25.48 25.48 0 0 0-9.92-13.77A28.81 28.81 0 0 0 537.4 60a30.42 30.42 0 0 0-18.64 5.95q-5 3.72-9.31 13.12ZM621.89 41.08h25.39v10.37q8.64-7.29 15.65-10.13a37.82 37.82 0 0 1 14.35-2.85A34.77 34.77 0 0 1 702.83 49q8.82 8.94 8.82 26.42v66.88h-25.11V98q0-18.12-1.63-24.06a16.44 16.44 0 0 0-5.66-9.06 15.8 15.8 0 0 0-10-3.11 18.73 18.73 0 0 0-13.23 5.15q-5.49 5.08-7.62 14.22-1.12 4.74-1.12 20.54v40.6h-25.39ZM750.71 3.78h25.39v37.3h15.07v21.85H776.1v79.35h-25.39V62.93h-13V41.08h13ZM826.09-.6a15.55 15.55 0 0 1 11.45 4.84A16.08 16.08 0 0 1 842.31 16a15.87 15.87 0 0 1-4.72 11.58 15.34 15.34 0 0 1-11.32 4.79 15.6 15.6 0 0 1-11.55-4.88 16.35 16.35 0 0 1-4.72-11.9 15.57 15.57 0 0 1 4.73-11.44A15.53 15.53 0 0 1 826.09-.6ZM813.39 41.08h25.39v101.2h-25.39zM873.47 2h25.39v80.8l37.39-41.72h31.89l-43.59 48.5 48.81 52.7h-31.53l-43-46.64v46.64h-25.36Z"
/>
</svg>
<p>Server is starting up. Refreshing in a few seconds...</p>
<div class="container" aria-hidden="true">
<canvas id="waves-canvas"></canvas>
</div>
<script type="text/javascript">
"use strict";
let timeoutID = -1;
function checkStatus() {
console.log("Checking server status...");
return fetch(window.location.href, {
method: "HEAD",
headers: {
"content-type": "application/json",
},
})
.then((response) => {
if (response.ok) {
console.log("Server is ready! Reloading...");
clearTimeout(timeoutID);
window.location.reload();
return;
}
if (response.status === 503) {
throw new Error("Server is still starting up...");
}
})
.catch((error) => {
console.warn(`Checking server status failed: ${error}`);
timeoutID = setTimeout(checkStatus, 2000);
});
}
const queryParams = new URLSearchParams(window.location.search);
// if (window.location.origin.startsWith("http") && !queryParams.has("debug")) {
// console.log("Checking server status...");
// checkStatus();
// }
</script>
<script type="module">
/**
* @file Wave animation module.
*/
/**
* Data offsets (11 properties per particle)
*/
const ParticleOffsets = {
BASE_X: 0,
BASE_Z: 1,
BASE_Y: 2,
R: 3,
G: 4,
B: 5,
A: 6,
SIZE: 7,
PERSPECTIVE_SIZE: 8,
HALF_SIZE: 9,
PERSPECTIVE_DEPTH_ALPHA: 10,
PARTICLE_SIZE: 11,
};
/**
* @typedef {object} Particle
* @property {number} baseX
* @property {number} baseZ
* @property {number} baseY
* @property {number} x
* @property {number} y
* @property {number} r
* @property {number} g
* @property {number} b
* @property {number} a
* @property {number} size
* @property {number} perspectiveSize
* @property {number} halfSize
* @property {number} perspectiveDepthAlpha
*/
class WavesCanvas {
//#region Properties
width = 0;
height = 0;
dpi = Math.max(1, devicePixelRatio);
// Wave parameters
turbulence = Math.PI * 6.0;
pointSize = 1 * this.dpi;
pointSizeCutoff = 5;
speed = 10;
waveHeight = 5;
distance = 3;
fov = (60 * Math.PI) / 180;
aspectRatio = 1;
cameraZ = 95;
lodThreshold = this.cameraZ * 0.9;
/**
* Tangent of the field of view, i.e the angle between the horizon and the camera
*/
f = Math.tan(Math.PI * 0.5 - 0.5 * this.fov);
/**
* Flat array storing particle data:
* [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
* @type {Float32Array}
*/
#particleData = new Float32Array(0);
#particleOffsetCount = this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
// Performance optimizations
/**
* @type {ImageData}
*/
// @ts-expect-error - Assigned in resize listener
#buffer;
#gridWidth = 0;
#gridDepth = 0;
#fieldWidth = 0;
#fieldHeight = 0;
#fieldDepth = 0;
#relativeWidth = 0;
#relativeHeight = 0;
// Frustum culling bounds
#frustumLeft = 0;
#frustumRight = 0;
#frustumTop = 0;
#frustumBottom = 0;
#frustumNear = 0;
#frustumFar = 0;
/**
* @type {HTMLCanvasElement}
*/
#canvas;
/**
* @type {CanvasRenderingContext2D}
*/
#ctx;
//#endregion
//#region Lifecycle
/**
* @param {string | HTMLCanvasElement} target
* @param {"circle" | "triangle"} shape
*/
constructor(target, shape = "circle") {
const element =
typeof target === "string" ? document.querySelector(target) : target;
if (!(element instanceof HTMLCanvasElement)) {
throw new TypeError("Invalid canvas element");
}
this.#canvas = element;
const ctx = this.#canvas.getContext("2d", {
alpha: false,
desynchronized: true,
});
if (!ctx) {
throw new TypeError("Failed to get 2D context");
}
this.#ctx = ctx;
// We apply a background color to the canvas element itself for a slight performance boost.
this.#canvas.style.background = getComputedStyle(document.body).backgroundColor;
// All containment rules are applied to the element to further improve performance.
this.#canvas.style.contain = "strict";
// Applying a null transform on the canvas forces the browser to use the GPU for rendering.
this.#canvas.style.willChange = "transform";
this.#canvas.style.transform = "translate3d(0, 0, 0)";
this.#canvas.style.pointerEvents = "none";
this.drawShape =
shape === "circle" ? this.#drawCircleParticle : this.#drawTriangleParticle;
window.addEventListener("resize", this.refresh);
const colorSchemeChangeListener = window.matchMedia(
"(prefers-color-scheme: dark)",
);
colorSchemeChangeListener.addEventListener("change", this.refresh);
}
refresh = () => {
const container = this.#canvas.parentElement;
if (!container) return;
this.width = container.offsetWidth;
this.height = container.offsetHeight;
this.aspectRatio = this.width / this.height;
this.#relativeWidth = this.width * this.dpi;
this.#relativeHeight = this.height * this.dpi;
this.#canvas.width = this.#relativeWidth;
this.#canvas.height = this.#relativeHeight;
this.#canvas.style.width = this.width + "px";
this.#canvas.style.height = this.height + "px";
this.#ctx.scale(this.dpi, this.dpi);
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
this.#buffer = this.#ctx.createImageData(
this.width * this.dpi,
this.height * this.dpi,
);
this.#gridWidth = 300 * this.aspectRatio;
this.#gridDepth = 300;
this.#fieldWidth = this.#gridWidth;
this.#fieldHeight = this.waveHeight * this.aspectRatio;
this.#fieldDepth = this.#gridDepth;
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
);
if (prefersReducedMotion.matches) {
this.speed = 5;
}
this.#calculateFrustumBounds();
this.#generateParticles();
};
//#endregion
//#region Frustum Culling
#calculateFrustumBounds() {
const halfFov = this.fov / 2;
const tanHalfFov = Math.tan(halfFov);
this.#frustumNear = 1;
this.#frustumFar = this.cameraZ + this.#gridDepth;
const nearHeight = 2 * this.#frustumNear * tanHalfFov;
const nearWidth = nearHeight * this.aspectRatio;
this.#frustumLeft = -nearWidth / 2;
this.#frustumRight = nearWidth / 2;
this.#frustumTop = -1 * (this.aspectRatio / this.height);
this.#frustumBottom = -1.25 * nearHeight;
}
/**
* Test if a 3D point is within the view frustum
* @param {number} x - World X coordinate
* @param {number} y - World Y coordinate
* @param {number} z - World Z coordinate
* @returns {boolean}
*/
#isInFrustum(x, y, z) {
const projectedZ = z + this.cameraZ;
// Behind camera or too far
if (projectedZ <= this.#frustumNear || projectedZ > this.#frustumFar) {
return false;
}
// Calculate frustum bounds at this depth
const scale = projectedZ / this.#frustumNear;
const left = this.#frustumLeft * scale;
const right = this.#frustumRight * scale;
const top = this.#frustumTop * scale;
const bottom = this.#frustumBottom * scale;
// Test if point is within frustum bounds
return x >= left && x <= right && y >= bottom && y <= top;
}
//#endregion
//#region Particle Generation
#generateParticles() {
const particleCount =
Math.floor(this.#gridWidth / this.distance) *
Math.floor(this.#gridDepth / this.distance);
this.#particleData = new Float32Array(
particleCount * ParticleOffsets.PARTICLE_SIZE,
);
this.#particleOffsetCount =
this.#particleData.length / ParticleOffsets.PARTICLE_SIZE;
let particleIndex = 0;
for (let x = 0; x < this.#gridWidth; x += this.distance) {
const baseX = Math.round(-this.#gridWidth / 2 + x);
for (let z = 0; z < this.#gridDepth; z += this.distance) {
const baseZ = -this.#gridDepth / 2 + z;
const depthFactor = (z / this.#gridDepth) * 0.9;
const horizonFactor = (x / this.#gridWidth) * 1;
// Pre-calculate perspective size based on fixed depth
const projectedZ = baseZ + this.cameraZ;
const baseSize = (this.height / 250) * this.pointSize * this.dpi;
// More exaggerated scaling with logarithmic falloff for horizon effect
const normalizedDepth = Math.max(0, (projectedZ - 50) / 200);
// Log scale for dramatic falloff
const logScale = Math.log(1 + normalizedDepth * 4) / Math.log(6);
// Invert so closer = larger
const distanceFactor = Math.max(0.05, 1 - logScale);
const perspectiveSize = baseSize * distanceFactor * 3;
const alpha = Math.max(Math.floor(z / this.#gridDepth), 0.85);
const depthAlpha = Math.max(0.6, Math.pow(distanceFactor, 2));
const offset = particleIndex * ParticleOffsets.PARTICLE_SIZE;
// Store particle data in flat array
this.#particleData[offset + ParticleOffsets.BASE_X] = baseX;
this.#particleData[offset + ParticleOffsets.BASE_Z] = baseZ;
this.#particleData[offset + ParticleOffsets.BASE_Y] =
this.cameraZ * -0.4;
this.#particleData[offset + ParticleOffsets.R] = Math.min(
Math.floor(253 / (depthFactor * horizonFactor)),
255,
);
this.#particleData[offset + ParticleOffsets.G] = Math.min(
Math.floor(75 / horizonFactor),
255,
);
this.#particleData[offset + ParticleOffsets.B] = Math.min(
Math.floor(45 / (depthFactor * horizonFactor * 2)),
255,
);
this.#particleData[offset + ParticleOffsets.A] = alpha;
this.#particleData[offset + ParticleOffsets.SIZE] = baseSize;
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE] =
perspectiveSize;
this.#particleData[offset + ParticleOffsets.HALF_SIZE] = Math.max(
1,
Math.round(perspectiveSize / 1.5),
);
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA] =
depthAlpha ** 2;
particleIndex++;
}
}
}
//#endregion
//#region Projection
/**
* @param {number} x
* @param {number} y
* @param {number} z
* @returns {{x: number, y: number, z: number} | null}
*/
project3DTo2D(x, y, z) {
const projectedX = (x * this.f) / this.aspectRatio;
const projectedY = y * this.f;
const projectedZ = z + this.cameraZ;
if (projectedZ <= 0) return null;
// Convert to screen coordinates
// top of canvas is horizon (y=0), bottom is near
const screenX = ((projectedX / projectedZ) * this.width) / 2 + this.width / 2;
const screenY =
this.height / 2 -
((projectedY / projectedZ) * this.height) / 2 -
this.cameraZ * 2;
return { x: screenX, y: screenY, z: projectedZ };
}
//#endregion
//#region Rendering
/**
* @param {number} x
* @param {number} z
* @param {number} time
* @returns {number}
*/
calculateWaveY(x, z, time) {
return (
(Math.cos((x / this.#fieldWidth) * this.turbulence + time * this.speed) +
Math.sin(
(z / this.#fieldDepth) * this.turbulence + time * this.speed,
)) *
this.#fieldHeight
);
}
/**
* Draw a simple point particle (for distant objects)
* @param {number} x
* @param {number} y
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawPointParticle(x, y, r, g, b, a) {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
if (centerX >= 0 && centerX < width && centerY >= 0 && centerY < height) {
const index = (centerY * width + centerX) * 4;
const alpha = Math.round(a * 255);
const blendFactor = alpha * 1.25;
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
data[index + 3] = 255;
}
}
/**
* Draw a hollow circle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawCircleParticle = (x, y, halfSize, r, g, b, a) => {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
const radius = halfSize;
// Draw hollow circle - just the outline
for (let py = -radius; py <= radius; py++) {
for (let px = -radius; px <= radius; px++) {
const distance = Math.sqrt(px * px + py * py);
// Only draw pixels that are on the circle edge (within 1 pixel of radius)
if (distance >= radius - 1 && distance <= radius) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (
xPixel >= 0 &&
xPixel < width &&
yPixel >= 0 &&
yPixel < height
) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(
255,
data[index] + (r * blendFactor) / 255,
);
data[index + 1] = Math.min(
255,
data[index + 1] + (g * blendFactor) / 255,
);
data[index + 2] = Math.min(
255,
data[index + 2] + (b * blendFactor) / 255,
);
data[index + 3] = 255;
}
}
}
}
};
/**
* Draw an isosceles triangle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawTriangleParticle = (x, y, halfSize, r, g, b, a) => {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
// Draw hollow isosceles triangle pointing up - just the outline
for (let py = -halfSize; py <= halfSize; py++) {
// Calculate max width at this height - steeper taper for more triangular shape
// 0 at top, 1 at bottom
const normalizedHeight = (py + halfSize) / (2 * halfSize);
const triangleWidth = Math.round(halfSize * normalizedHeight);
for (let px = -triangleWidth; px <= triangleWidth; px++) {
// Only draw if on the edge (left edge, right edge, or bottom edge)
const isLeftEdge = px === -triangleWidth;
const isRightEdge = px === triangleWidth;
const isBottomEdge = py === halfSize;
if (isLeftEdge || isRightEdge || isBottomEdge) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (
xPixel >= 0 &&
xPixel < width &&
yPixel >= 0 &&
yPixel < height
) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(
255,
data[index] + (r * blendFactor) / 255,
);
data[index + 1] = Math.min(
255,
data[index + 1] + (g * blendFactor) / 255,
);
data[index + 2] = Math.min(
255,
data[index + 2] + (b * blendFactor) / 255,
);
data[index + 3] = 255;
}
}
}
}
};
#render = (time = performance.now()) => {
const timestamp = time / 6000;
this.#buffer.data.fill(0);
for (let i = 0; i < this.#particleOffsetCount; i++) {
const offset = i * ParticleOffsets.PARTICLE_SIZE;
const baseX = this.#particleData[offset + ParticleOffsets.BASE_X];
const baseZ = this.#particleData[offset + ParticleOffsets.BASE_Z];
const baseY = this.#particleData[offset + ParticleOffsets.BASE_Y];
const waveY = this.calculateWaveY(baseX, baseZ, timestamp);
const worldY = baseY + waveY;
if (!this.#isInFrustum(baseX, worldY, baseZ)) {
continue;
}
const projected = this.project3DTo2D(baseX, worldY, baseZ);
if (
!projected ||
projected.x < -50 ||
projected.x > this.width + 50 ||
projected.y < 0 ||
projected.y > this.height + 50
) {
continue;
}
const distance = Math.abs(projected.z - this.cameraZ);
const perspectiveSize =
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_SIZE];
if (
distance > this.lodThreshold ||
perspectiveSize < this.pointSizeCutoff
) {
this.#drawPointParticle(
projected.x,
projected.y,
this.#particleData[offset + ParticleOffsets.R],
this.#particleData[offset + ParticleOffsets.G],
this.#particleData[offset + ParticleOffsets.B],
this.#particleData[
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
],
);
} else {
this.#drawCircleParticle(
projected.x,
projected.y,
this.#particleData[offset + ParticleOffsets.HALF_SIZE],
this.#particleData[offset + ParticleOffsets.R],
this.#particleData[offset + ParticleOffsets.G],
this.#particleData[offset + ParticleOffsets.B],
this.#particleData[
offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA
],
);
}
}
this.#ctx.putImageData(this.#buffer, 0, 0);
this.#ctx.globalCompositeOperation = "soft-light";
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
this.#ctx.globalCompositeOperation = "source-over";
this.#renderFrameID = requestAnimationFrame(this.#render);
};
//#endregion
//#region Public Methods
#renderFrameID = -1;
play = () => {
this.refresh();
this.#render();
};
pause = () => {
cancelAnimationFrame(this.#renderFrameID);
};
}
function drawAnimation() {
const canvas = document.getElementById("waves-canvas");
const waves = new WavesCanvas(canvas);
waves.play();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", drawAnimation);
} else {
drawAnimation();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<meta name="color-scheme" content="light dark" />
<title>authentik</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 100%;
}
body {
font-size: clamp(16px, 3dvw, 20px);
background-color: #000;
background-color: Canvas;
color: #fff;
color: CanvasText;
font-family: system-ui, ui-sans-serif, sans-serif;
text-align: center;
display: grid;
place-content: center;
place-items: center;
min-height: 100dvh;
margin: 0;
padding-block: 0 6em;
padding-inline: 0.5em;
}
.logo {
height: 3.5em;
max-width: 90dvw;
}
.container {
position: absolute;
left: 0;
top: 50%;
right: 0;
bottom: 0;
z-index: -1;
overflow: hidden;
}
.container canvas {
display: block;
}
</style>
</head>
<body>
<svg
class="logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 144.29"
aria-label="authentik"
aria-role="heading"
aria-level="1"
preserveAspectRatio="xMidYMid meet"
>
<path
fill="currentColor"
d="M106 41.08h25.39v101.2H106v-10.7a50 50 0 0 1-14.92 10.19 41.84 41.84 0 0 1-16.21 3.11q-19.61 0-33.91-15.21T26.64 91.86q0-23.43 13.85-38.41t33.63-15a42.78 42.78 0 0 1 17.09 3.44A46.82 46.82 0 0 1 106 52.24ZM79.29 61.91a25.65 25.65 0 0 0-19.56 8.33q-7.78 8.33-7.79 21.34t7.93 21.58a25.66 25.66 0 0 0 19.51 8.47 26.15 26.15 0 0 0 19.84-8.33q7.88-8.33 7.88-21.81 0-13.2-7.88-21.39t-19.93-8.19ZM168.39 41.08h25.67v48.74q0 14.22 2 19.76a17.24 17.24 0 0 0 6.29 8.61 18.06 18.06 0 0 0 10.65 3.07 18.6 18.6 0 0 0 10.77-3 17.7 17.7 0 0 0 6.57-8.88q1.59-4.36 1.59-18.7v-49.6h25.39V84q0 26.51-4.18 36.27a39.6 39.6 0 0 1-15.07 18.28q-10 6.38-25.3 6.37-16.65 0-26.93-7.44t-14.48-20.78q-3-9.21-3-33.49ZM297.3 3.78h25.39v37.3h15.07v21.85h-15.07v79.35H297.3V62.93h-13V41.08h13ZM362.86 2h25.21v49.3a57.74 57.74 0 0 1 15-9.63 38.56 38.56 0 0 1 15.25-3.21 34.36 34.36 0 0 1 25.39 10.42q8.83 9 8.84 26.51v66.88h-25V97.91q0-17.58-1.68-23.81t-5.71-9.3a16.07 16.07 0 0 0-10-3.07 18.85 18.85 0 0 0-13.26 5.11q-5.53 5.11-7.67 14-1.12 4.56-1.12 20.84v40.65h-25.25ZM589.91 99h-81.58q1.77 10.78 9.44 17.16t19.58 6.37a33.86 33.86 0 0 0 24.46-10l21.4 10a50.54 50.54 0 0 1-19.16 16.79q-11.16 5.44-26.51 5.44-23.82 0-38.79-15t-15-37.63q0-23.16 14.93-38.46t37.44-15.3q23.91 0 38.88 15.3t15 40.42Zm-25.4-20a25.48 25.48 0 0 0-9.92-13.77A28.81 28.81 0 0 0 537.4 60a30.42 30.42 0 0 0-18.64 5.95q-5 3.72-9.31 13.12ZM621.89 41.08h25.39v10.37q8.64-7.29 15.65-10.13a37.82 37.82 0 0 1 14.35-2.85A34.77 34.77 0 0 1 702.83 49q8.82 8.94 8.82 26.42v66.88h-25.11V98q0-18.12-1.63-24.06a16.44 16.44 0 0 0-5.66-9.06 15.8 15.8 0 0 0-10-3.11 18.73 18.73 0 0 0-13.23 5.15q-5.49 5.08-7.62 14.22-1.12 4.74-1.12 20.54v40.6h-25.39ZM750.71 3.78h25.39v37.3h15.07v21.85H776.1v79.35h-25.39V62.93h-13V41.08h13ZM826.09-.6a15.55 15.55 0 0 1 11.45 4.84A16.08 16.08 0 0 1 842.31 16a15.87 15.87 0 0 1-4.72 11.58 15.34 15.34 0 0 1-11.32 4.79 15.6 15.6 0 0 1-11.55-4.88 16.35 16.35 0 0 1-4.72-11.9 15.57 15.57 0 0 1 4.73-11.44A15.53 15.53 0 0 1 826.09-.6ZM813.39 41.08h25.39v101.2h-25.39zM873.47 2h25.39v80.8l37.39-41.72h31.89l-43.59 48.5 48.81 52.7h-31.53l-43-46.64v46.64h-25.36Z"
/>
</svg>
<p>Server is starting up. Refreshing in a few seconds...</p>
<div class="container" aria-hidden="true">
<canvas id="waves-canvas"></canvas>
</div>
<script type="text/javascript">
"use strict";
let timeoutID = -1;
function checkStatus() {
console.log("Checking server status...");
return fetch(window.location.href, {
method: "HEAD",
headers: {
"content-type": "application/json",
},
})
.then((response) => {
if (response.ok) {
console.log("Server is ready! Reloading...");
clearTimeout(timeoutID);
window.location.reload();
return;
}
if (response.status === 503) {
throw new Error("Server is still starting up...");
}
})
.catch((error) => {
console.warn(`Checking server status failed: ${error}`);
timeoutID = setTimeout(checkStatus, 2000);
});
}
const queryParams = new URLSearchParams(window.location.search);
if (window.location.origin.startsWith("http") && !queryParams.has("debug")) {
console.log("Checking server status...");
checkStatus();
}
</script>
<script type="module">
import("/static/dist/standalone/loading/wave-boi.mjs")
.then(({ WavesCanvas }) => {
function drawAnimation() {
const canvas = document.getElementById("waves-canvas");
const waves = new WavesCanvas(canvas);
waves.play();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", drawAnimation);
} else {
drawAnimation();
}
})
.catch((error) => {
console.debug("Skipping waves canvas:", error);
});
</script>
</body>
</html>

View File

@@ -45,13 +45,13 @@ export class WavesCanvas {
// Wave parameters
turbulence = Math.PI * 6.0;
pointSize = 1.5 * this.dpi;
pointSize = 1 * this.dpi;
pointSizeCutoff = 5;
speed = 10;
waveHeight = 5;
distance = 2;
fov = (60 * Math.PI) / 180;
distance = 3;
fov = (60 * Math.PI) / 180;
aspectRatio = 1;
cameraZ = 95;
@@ -63,7 +63,8 @@ export class WavesCanvas {
f = Math.tan(Math.PI * 0.5 - 0.5 * this.fov);
/**
* Flat array storing particle data: [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
* Flat array storing particle data:
* [baseX, baseZ, baseY, r, g, b, a, size, perspectiveSize, halfSize, perspectiveDepthAlpha]
* @type {Float32Array}
*/
#particleData = new Float32Array(0);
@@ -76,6 +77,12 @@ export class WavesCanvas {
// @ts-expect-error - Assigned in resize listener
#buffer;
/**
* @type {Uint8ClampedArray}
*/
// @ts-expect-error - Assigned in resize listener
#sample;
#gridWidth = 0;
#gridDepth = 0;
#fieldWidth = 0;
@@ -103,14 +110,21 @@ export class WavesCanvas {
*/
#ctx;
/**
* The method to use to draw the waves.
* @type {(x: number, y: number, halfSize: number, r: number, g: number, b: number, a: number) => void}
*/
#drawShape;
//#endregion
//#region Lifecycle
/**
* @param {string | HTMLCanvasElement} target
* @param {"circle" | "triangle"} shape
*/
constructor(target) {
constructor(target, shape = "circle") {
const element = typeof target === "string" ? document.querySelector(target) : target;
if (!(element instanceof HTMLCanvasElement)) {
@@ -139,6 +153,9 @@ export class WavesCanvas {
this.#canvas.style.transform = "translate3d(0, 0, 0)";
this.#canvas.style.pointerEvents = "none";
this.#drawShape =
shape === "circle" ? this.#drawCircleParticle : this.#drawTriangleParticle;
window.addEventListener("resize", this.refresh);
const colorSchemeChangeListener = window.matchMedia("(prefers-color-scheme: dark)");
@@ -165,8 +182,6 @@ export class WavesCanvas {
this.#ctx.scale(this.dpi, this.dpi);
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
this.#buffer = this.#ctx.createImageData(this.width * this.dpi, this.height * this.dpi);
this.#gridWidth = 300 * this.aspectRatio;
@@ -176,8 +191,20 @@ export class WavesCanvas {
this.#fieldHeight = this.waveHeight * this.aspectRatio;
this.#fieldDepth = this.#gridDepth;
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
if (prefersReducedMotion.matches) {
this.speed = 5;
}
this.#calculateFrustumBounds();
this.#generateParticles();
this.#ctx.fillStyle = getComputedStyle(document.body).backgroundColor;
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
// Grab a single pixel from the canvas and store it for later blending.
this.#sample = this.#ctx.getImageData(0, 0, 1, 1).data;
};
//#endregion
@@ -188,19 +215,16 @@ export class WavesCanvas {
const halfFov = this.fov / 2;
const tanHalfFov = Math.tan(halfFov);
// Calculate frustum bounds at different depths
this.#frustumNear = 1;
this.#frustumFar = this.cameraZ + this.#gridDepth;
// At near plane
const nearHeight = 2 * this.#frustumNear * tanHalfFov;
const nearWidth = nearHeight * this.aspectRatio;
// Store half-widths and half-heights for faster testing
this.#frustumLeft = -nearWidth / 2;
this.#frustumRight = nearWidth / 2;
this.#frustumTop = nearHeight / 2;
this.#frustumBottom = -nearHeight;
this.#frustumTop = -1 * (this.aspectRatio / this.height);
this.#frustumBottom = -1.25 * nearHeight;
}
/**
@@ -373,6 +397,53 @@ export class WavesCanvas {
}
}
/**
* Draw a hollow circle particle
* @param {number} x
* @param {number} y
* @param {number} halfSize
* @param {number} r
* @param {number} g
* @param {number} b
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawCircleParticle = (x, y, halfSize, r, g, b, a) => {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
const width = this.#relativeWidth;
const height = this.#relativeHeight;
const alpha = Math.round(a * 255);
const radius = halfSize;
// Draw hollow circle - just the outline
for (let py = -radius; py <= radius; py++) {
for (let px = -radius; px <= radius; px++) {
const distance = Math.sqrt(px * px + py * py);
// Only draw pixels that are on the circle edge (within 1 pixel of radius)
if (distance >= radius - 1 && distance <= radius) {
const xPixel = centerX + px;
const yPixel = centerY + py;
if (xPixel >= 0 && xPixel < width && yPixel >= 0 && yPixel < height) {
const index = (yPixel * width + xPixel) * 4;
const blendFactor = alpha * 1.25;
// Additive blending for glow effect
data[index] = Math.min(255, data[index] + (r * blendFactor) / 255);
data[index + 1] = Math.min(255, data[index + 1] + (g * blendFactor) / 255);
data[index + 2] = Math.min(255, data[index + 2] + (b * blendFactor) / 255);
data[index + 3] = 255;
}
}
}
}
};
/**
* Draw an isosceles triangle particle
* @param {number} x
@@ -384,7 +455,7 @@ export class WavesCanvas {
* @param {number} a
*/
// eslint-disable-next-line max-params
#drawTriangleParticle(x, y, halfSize, r, g, b, a) {
#drawTriangleParticle = (x, y, halfSize, r, g, b, a) => {
const data = this.#buffer.data;
const centerX = Math.round(x * this.dpi);
const centerY = Math.round(y * this.dpi);
@@ -424,10 +495,11 @@ export class WavesCanvas {
}
}
}
}
};
#render = (time = performance.now()) => {
const timestamp = time / 6000;
this.#buffer.data.fill(0);
for (let i = 0; i < this.#particleOffsetCount; i++) {
@@ -469,8 +541,7 @@ export class WavesCanvas {
this.#particleData[offset + ParticleOffsets.PERSPECTIVE_DEPTH_ALPHA],
);
} else {
// Close enough for detailed triangle
this.#drawTriangleParticle(
this.#drawShape(
projected.x,
projected.y,
this.#particleData[offset + ParticleOffsets.HALF_SIZE],
@@ -482,10 +553,16 @@ export class WavesCanvas {
}
}
// Image data doesn't support blending, so we need to do it manually.
for (let i = 0; i < this.#buffer.data.length; i += 4) {
this.#buffer.data[i] ||= this.#sample[0];
this.#buffer.data[i + 1] ||= this.#sample[1];
this.#buffer.data[i + 2] ||= this.#sample[2];
this.#buffer.data[i + 3] ||= this.#sample[3];
}
this.#ctx.putImageData(this.#buffer, 0, 0);
this.#ctx.globalCompositeOperation = "soft-light";
this.#ctx.fillRect(0, 0, this.#relativeWidth, this.#relativeHeight);
this.#ctx.globalCompositeOperation = "source-over";
this.#renderFrameID = requestAnimationFrame(this.#render);
};

View File

@@ -71,7 +71,7 @@ export class LibraryPageApplicationEmptyList
return html` <div class="pf-c-empty-state pf-m-full-height">
<div class="pf-c-empty-state__content">
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">${msg("No Applications available.")}</h1>
<h2 class="pf-c-title pf-m-lg">${msg("No Applications available.")}</h2>
<div class="pf-c-empty-state__body">
${msg("Either no applications are defined, or you dont have access to any.")}
</div>

View File

@@ -182,13 +182,16 @@ export class LibraryPage extends AKElement {
}
render() {
return html`<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
<div class="pf-c-content header">
<h1 role="heading" aria-level="1" id="library-page-title">
${msg("My applications")}
</h1>
return html`<main
aria-label=${msg("Applications library")}
class="pf-c-page__main"
tabindex="-1"
id="main-content"
>
<header class="pf-c-content header">
<h1>${msg("My applications")}</h1>
${this.uiConfig.searchEnabled ? this.renderSearch() : nothing}
</div>
</header>
<section class="pf-c-page__main-section">${this.renderState()}</section>
</main>`;
}

View File

@@ -222,7 +222,7 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<main class="pf-c-page__main">
<div class="pf-c-page__main">
<ak-router-outlet
class="pf-l-bullseye__item pf-c-page__main"
tabindex="-1"
@@ -231,7 +231,7 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {
.routes=${ROUTES}
>
</ak-router-outlet>
</main>
</div>
</div>
</div>
<ak-notification-drawer

View File

@@ -0,0 +1,217 @@
import { expect, test } from "#e2e";
import { createRandomName } from "#e2e/utils/generators";
import { ConsoleLogger } from "#logger/node";
import { IDGenerator } from "@goauthentik/core/id";
import { series } from "@goauthentik/core/promises";
test.describe("Provider Wizard", () => {
const providerNames = new Map<string, string>();
//#region Lifecycle
test.beforeEach("Configure Providers", async ({ page, session }, { testId }) => {
const seed = IDGenerator.randomID(6);
const providerName = `${createRandomName({ seed })} (${seed})`;
providerNames.set(testId, providerName);
const wizard = page.getByRole("dialog", { name: "New provider" });
await test.step("Authenticate", async () => {
await session.login({
to: "/if/admin/#/core/providers",
});
});
await test.step("Navigate to provider wizard", async () => {
await expect(wizard, "Wizard is initially closed").toBeHidden();
await page.getByRole("button", { name: "New Provider" }).click();
await expect(wizard, "Wizard opens after clicking on New Provider").toBeVisible();
await expect(
page.getByRole("listbox", { name: "Select a provider type" }),
"Wizard opens with a list of provider types",
).toBeVisible();
await expect(
wizard.getByRole("navigation").getByRole("button", {
name: /next|finish/i,
}),
"Wizard can't be navigated to next step",
).toBeDisabled();
});
});
test.afterEach("Verification", async ({ page }, { testId }) => {
//#region Confirm provider
const providerName = providerNames.get(testId)!;
const $provider = await test.step("Find provider via search", async () => {
const searchInput = page.getByRole("search").getByPlaceholder("Search for providers");
await searchInput.fill(providerName);
// We have to wait for the provider to appear in the table,
// but several UI elements will be rendered asynchronously.
// We attempt several times to find the provider to avoid flakiness.
const tries = 10;
let found = false;
for (let i = 0; i < tries; i++) {
await searchInput.press("Enter");
await searchInput.blur();
const $rowEntry = page.getByRole("row", {
name: providerName,
});
ConsoleLogger.info(
`${i + 1}/${tries} Waiting for provider ${providerName} to appear in the table`,
);
found = await $rowEntry
.waitFor({
timeout: 1500,
})
.then(() => true)
.catch(() => false);
if (found) {
ConsoleLogger.info(`Provider ${providerName} found in the table`);
return $rowEntry;
}
}
throw new Error(`Provider ${providerName} not found in the table`);
});
await expect($provider, "Provider is visible").toBeVisible();
//#endregion
});
//#endregion
//#region OAuth2
test("Simple OAuth2 Provider", async ({ form, pointer }, testInfo) => {
const providerName = providerNames.get(testInfo.testId)!;
const { fill, selectSearchValue } = form;
const { click } = pointer;
await series(
[click, "OAuth2/OpenID", "option"],
[click, "Next"],
[fill, "Provider name", providerName],
[
selectSearchValue,
"Authorization flow",
/default-provider-authorization-explicit-consent/,
],
[click, "Finish"],
);
});
test("Complete OAuth2 Provider", async ({ page, form, pointer }, testInfo) => {
const providerName = providerNames.get(testInfo.testId)!;
const { fill, selectSearchValue, setFormGroup, setRadio, setInputCheck } = form;
const { click } = pointer;
const $clientSecretInput = page.getByRole("textbox", { name: "Client Secret" });
await series(
[click, "OAuth2/OpenID", "option"],
[click, "Next"],
[fill, "Provider name", providerName],
[
selectSearchValue,
"Authorization flow",
/default-provider-authorization-explicit-consent/,
],
[setFormGroup, "Protocol settings", true],
[setRadio, "Client Type", "Public"],
[
expect(
$clientSecretInput,
"Client Secret should be hidden when Client Type is Public",
).toBeHidden,
],
[setRadio, "Client Type", "Confidential"],
[
expect(
$clientSecretInput,
"Client Secret should be visible when Client Type is Confidential",
).toBeVisible,
],
[selectSearchValue, "Signing Key", /authentik Self-signed Certificate/],
[selectSearchValue, "Encryption Key", /authentik Self-signed Certificate/],
[setFormGroup, "Advanced flow settings", true],
[selectSearchValue, "Authentication flow", /default-source-authentication/],
[selectSearchValue, "Invalidation flow", /default-invalidation-flow/],
[setFormGroup, "Advanced protocol settings", true],
[fill, "Access code validity", "minutes=2"],
[fill, "Access token validity", "minutes=10"],
[fill, "Refresh token validity", "days=40"],
[setInputCheck, "Include claims in id_token", false],
[setRadio, "Subject mode", "Based on the User's username"],
[setRadio, "Issuer mode", "Same identifier is used for all providers"],
[setFormGroup, "Machine-to-Machine authentication settings", true],
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
);
});
//#endregion
//#region LDAP
test("Complete LDAP Provider", async ({ page, pointer, form }, testInfo) => {
const providerName = providerNames.get(testInfo.testId)!;
const { fill, setFormGroup, selectSearchValue, setInputCheck, setRadio } = form;
const { click } = pointer;
await series(
[click, "LDAP", "option"],
[click, "Next"],
[fill, "Provider name", providerName],
[setFormGroup, "Flow settings", true],
[setFormGroup, "Protocol settings", true],
[selectSearchValue, "Bind flow", /default-authentication-flow/],
[fill, "Base DN", "DC=ldap-2,DC=goauthentik,DC=io"],
[selectSearchValue, "Certificate", /authentik Self-signed Certificate/],
[fill, "TLS Server name", "goauthentik.io"],
[fill, "UID start number", "2001"],
[fill, "GID start number", "4001"],
[setRadio, "Search mode", "Direct querying"],
[setRadio, "Bind mode", "Direct binding"],
[setInputCheck, "MFA Support", false],
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
);
});
//#endregion
//#region RADIUS
test("Complete RADIUS Provider", async ({ page, pointer, form }, testInfo) => {
const providerName = providerNames.get(testInfo.testId)!;
const { fill, selectSearchValue } = form;
const { click } = pointer;
await series(
[click, "RADIUS", "option"],
[click, "Next"],
[fill, "Provider name", providerName],
[selectSearchValue, "Authentication flow", /default-authentication-flow/],
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
);
});
//#endregion
});

View File

@@ -0,0 +1,35 @@
import { expect, test } from "#e2e";
import {
BAD_PASSWORD,
BAD_USERNAME,
GOOD_PASSWORD,
GOOD_USERNAME,
} from "#e2e/fixtures/SessionFixture";
test.beforeEach(async ({ session }) => {
await session.toLoginPage();
});
test.describe("Session management", () => {
test("Login with valid credentials", async ({ session, page }) => {
await session.login({ username: GOOD_USERNAME, password: GOOD_PASSWORD });
await expect(
page.getByRole("heading", {
level: 1,
}),
).toHaveText("My applications");
});
test("Reject bad username", async ({ session }) => {
await session.login({ username: BAD_USERNAME, password: GOOD_PASSWORD });
await expect(session.$authFailureMessage).toBeVisible();
});
test("Reject bad password", async ({ session }) => {
await session.login({ username: GOOD_USERNAME, password: BAD_PASSWORD });
await expect(session.$authFailureMessage).toBeVisible();
});
});

87
web/test/lit/rendering.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* @file Vitest browser utilities for Lit.
*
* @import { LocatorSelectors } from '@vitest/browser/context'
* @import { PrettyDOMOptions } from '@vitest/browser/utils'
* @import { RenderOptions as LitRenderOptions } from 'lit'
*/
import { debug, getElementLocatorSelectors } from "@vitest/browser/utils";
import { render as renderLit } from "lit";
/**
* @implements {Disposable}
*/
export class LitViteContext {
/**
* @type {Set<Disposable>}
*/
static #resources = new Set();
/**
* @param {unknown} template
* @param {HTMLElement} [container]
* @param {LitRenderOptions} [options]
*
* @returns {LitViteContext}
*/
static render = (template, container = document.createElement("div"), options) => {
const context = new LitViteContext(container);
context.render(template, options);
return context;
};
static [Symbol.dispose] = () => {
this.#resources.forEach((resource) => resource[Symbol.dispose]());
this.#resources.clear();
};
static cleanup = () => {
return this[Symbol.dispose]();
};
/**
* @param {unknown} template
* @param {LitRenderOptions} [options]
*/
render(template, options) {
return renderLit(template, this.container, options);
}
/**
* @type {HTMLElement} container
*/
container;
/**
* @type {LocatorSelectors}
*/
$;
/**
* @param {HTMLElement} container
*/
constructor(container) {
this.container = container;
this.$ = getElementLocatorSelectors(container);
}
toFragment() {
return document.createRange().createContextualFragment(this.container.innerHTML);
}
/**
* @param {number} [maxLength]
* @param {PrettyDOMOptions} [options]
*/
debug(maxLength, options) {
return debug(this.container, maxLength, options);
}
[Symbol.dispose] = () => {
this.container.remove();
LitViteContext.#resources.delete(this);
};
}

12
web/test/lit/setup.js Normal file
View File

@@ -0,0 +1,12 @@
import { LitViteContext } from "./rendering.js";
import { page } from "@vitest/browser/context";
import { beforeEach } from "vitest";
page.extend({
// @ts-ignore
renderLit: LitViteContext.render,
[Symbol.for("vitest:component-cleanup")]: LitViteContext.cleanup,
});
beforeEach(() => LitViteContext.cleanup());

View File

@@ -19,7 +19,7 @@ class LoginPage extends Page {
}
async inputPassword() {
return await $(">>>input#ak-stage-password-input");
return await $('>>>input[name="password"]');
}
async passwordBtnSubmit() {
@@ -53,7 +53,7 @@ class LoginPage extends Page {
await this.pause();
await this.password(password);
await this.pause();
await this.pause(">>>div.header h1");
await this.pause(">>>header h1");
return UserLibraryPage;
}

View File

@@ -11,7 +11,7 @@ class UserLibraryPage extends Page {
*/
public async pageHeader() {
return await $('>>>h1[aria-level="1"]');
return $(">>>header h1");
}
public async goToAdmin() {

View File

@@ -1,16 +0,0 @@
import LoginPage from "../pageobjects/login.page.js";
import { BAD_PASSWORD, GOOD_USERNAME } from "../utils/constants.js";
import { expect } from "@wdio/globals";
describe("Log into authentik", () => {
it("should fail on a bad password", async () => {
await LoginPage.open();
await LoginPage.username(GOOD_USERNAME);
await LoginPage.pause();
await LoginPage.password(BAD_PASSWORD);
const failure = await LoginPage.authFailure();
await expect(failure).toBeDisplayedInViewport();
await expect(failure).toHaveText("Invalid password");
});
});

View File

@@ -1,16 +0,0 @@
import LoginPage from "../pageobjects/login.page.js";
import { BAD_USERNAME, GOOD_PASSWORD } from "../utils/constants.js";
import { expect } from "@wdio/globals";
describe("Log into authentik", () => {
it("should fail on a bad username", async () => {
await LoginPage.open();
await LoginPage.username(BAD_USERNAME);
await LoginPage.pause();
await LoginPage.password(GOOD_PASSWORD);
const failure = await LoginPage.authFailure();
await expect(failure).toBeDisplayedInViewport();
await expect(failure).toHaveText("Invalid password");
});
});

View File

@@ -1,5 +0,0 @@
import { login } from "../utils/login.js";
describe("Log into authentik", () => {
it("should login with valid credentials and reach the UserLibrary", login);
});

View File

@@ -1,6 +1,5 @@
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"moduleResolution": "node",
"module": "ESNext",

View File

@@ -4,14 +4,15 @@
* @see https://webdriver.io/docs/configurationfile.html
*/
import { cwd } from "node:process";
import * as path from "node:path";
import { addCommands } from "../commands.mjs";
import litCSS from "#bundler/vite-plugin-lit-css/node";
import { createBundleDefinitions } from "#bundler/utils/node";
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
import { PackageRoot } from "#paths/node";
const NODE_ENV = process.env.NODE_ENV || "development";
const headless = !!process.env.HEADLESS || !!process.env.CI;
const headless = !process.env.HEADLESS || !!process.env.CI;
const lemmeSee = !!process.env.WDIO_LEMME_SEE;
/**
@@ -70,27 +71,24 @@ if (process.env.WDIO_TEST_FIREFOX) {
*/
const browserRunnerOptions = {
viteConfig: {
define: {
"process.env.NODE_ENV": JSON.stringify(NODE_ENV),
"process.env.CWD": JSON.stringify(cwd()),
"process.env.AK_API_BASE_PATH": JSON.stringify(process.env.AK_API_BASE_PATH || ""),
},
define: createBundleDefinitions(),
plugins: [
// ---
// @ts-ignore WDIO's Vite is out of date.
litCSS(),
inlineCSSPlugin(),
],
},
};
/**
* @satisfies {WebdriverIO.Config}
*/
export const config = {
runner: ["browser", browserRunnerOptions],
tsConfigPath: "./tsconfig.test.json",
tsConfigPath: path.resolve(PackageRoot, "tests", "tsconfig.test.json"),
specs: [path.resolve(PackageRoot, "tests", "specs", "**", "*.ts")],
specs: ["./src/**/*.test.ts"],
exclude: [],
maxInstances,

View File

@@ -2,5 +2,11 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts", "./tests"]
"exclude": [
// ---
"src/**/*.test.ts",
"src/**/*.comp.ts",
"./**/*.stories.ts",
"./tests"
]
}

23
web/types/node.d.ts vendored
View File

@@ -14,12 +14,13 @@ declare module "module" {
* const relativeDirname = dirname(fileURLToPath(import.meta.url));
* ```
*/
var __dirname: string;
}
}
declare module "process" {
import { Level } from "pino";
global {
namespace NodeJS {
interface ProcessEnv {
@@ -30,6 +31,26 @@ declare module "process" {
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
*/
readonly NODE_ENV?: "development" | "production";
/**
* Whether or not we are running on a CI server.
*/
readonly CI?: string;
/**
* The application log level.
*/
readonly AK_LOG_LEVEL?: Level;
/**
* The base URL of web server to run the tests against.
*
* Typically this is `http://localhost:9000`.
*
* @format url
*/
readonly AK_TEST_RUNNER_PAGE_URL?: string;
/**
* @todo Determine where this is used and if it is needed,
* give it a better name.

View File

@@ -1,3 +1,5 @@
/// <reference types="vitest/config" />
import { createBundleDefinitions } from "#bundler/utils/node";
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
@@ -9,4 +11,41 @@ export default defineConfig({
// ---
inlineCSSPlugin(),
],
test: {
dir: "./test",
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/out/**",
"**/.{idea,git,cache,output,temp}/**",
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
],
projects: [
{
test: {
include: ["./unit/**/*.{test,spec}.ts", "**/*.unit.{test,spec}.ts"],
name: "unit",
environment: "node",
},
},
{
test: {
setupFiles: ["./test/lit/setup.js"],
include: ["./browser/**/*.{test,spec}.ts", "**/*.browser.{test,spec}.ts"],
name: "browser",
browser: {
enabled: true,
provider: "playwright",
instances: [
{
browser: "chromium",
},
],
},
},
},
],
},
});

View File

@@ -1,16 +1,23 @@
import { addCommands } from "./commands.mjs";
/**
* @file WebdriverIO configuration file for **integration tests**.
*
* @see https://webdriver.io/docs/configurationfile.html
*/
import * as path from "node:path";
import { addCommands } from "./commands.mjs";
import { createBundleDefinitions } from "#bundler/utils/node";
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
import { PackageRoot } from "#paths/node";
import { browser } from "@wdio/globals";
/// <reference types="@wdio/globals/types" />
/// <reference types="./types/webdriver.js" />
const headless = !!process.env.CI;
const headless = !process.env.HEADLESS || !!process.env.CI;
const lemmeSee = !!process.env.WDIO_LEMME_SEE;
/**
@@ -24,21 +31,18 @@ if (!process.env.WDIO_SKIP_CHROME) {
*/
const chromeBrowserConfig = {
"browserName": "chrome",
// "wdio:chromedriverOptions": {
// binary: "./node_modules/.bin/chromedriver",
// },
"goog:chromeOptions": {
args: ["disable-infobars", "window-size=1280,800"],
args: ["disable-search-engine-choice-screen"],
},
};
if (headless) {
chromeBrowserConfig["goog:chromeOptions"].args.push(
"headless",
"no-sandbox",
"disable-gpu",
"disable-setuid-sandbox",
"disable-dev-shm-usage",
"no-sandbox",
"window-size=1280,672",
"browser-test",
);
}
@@ -57,17 +61,29 @@ if (process.env.WDIO_TEST_FIREFOX) {
});
}
/**
* @type {WebdriverIO.BrowserRunnerOptions}
*/
const browserRunnerOptions = {
viteConfig: {
define: createBundleDefinitions(),
plugins: [
// ---
inlineCSSPlugin(),
],
},
};
/**
* @satisfies {WebdriverIO.Config}
*/
export const config = {
runner: "local",
tsConfigPath: "./tsconfig.json",
runner: ["browser", browserRunnerOptions],
tsConfigPath: path.resolve(PackageRoot, "tsconfig.test.json"),
specs: [path.resolve(PackageRoot, "src", "**", "*.test.ts")],
specs: [
// "./tests/specs/**/*.ts"
"./tests/specs/new-application-by-wizard.ts",
],
exclude: [],
maxInstances: 1,
capabilities,
@@ -84,13 +100,11 @@ export const config = {
ui: "bdd",
timeout: 60000,
},
/**
* @param {WebdriverIO.Capabilities} capabilities
* @param {string[]} specs
* @param {WebdriverIO.Browser} browser
* @returns {void}
*/
before(capabilities, specs, browser) {
before(_capabilities, _specs, browser) {
addCommands(browser);
},

View File

@@ -9,7 +9,6 @@ tags:
- docker
---
import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";
import TabItem from "@theme/TabItem";
import Tabs from "@theme/Tabs";
@@ -24,6 +23,7 @@ Before you begin, ensure you have the following tools installed:
- [PostgreSQL](https://www.postgresql.org/) (16 or later)
- [Docker](https://www.docker.com/) (Latest Community Edition or Docker Desktop)
- [Docker Compose](https://docs.docker.com/compose/) (Compose v2)
- [Make](https://www.gnu.org/software/make/) (3 or later)
## 1. Setting Up Required Services
@@ -52,8 +52,8 @@ If using locally installed databases, ensure the PostgreSQL credentials provided
## 2. Installing Platform-Specific Dependencies
<Tabs defaultValue="Mac">
<TabItem value="Mac">
<Tabs defaultValue="macOS">
<TabItem value="macOS">
Install the required native dependencies on macOS using Homebrew:
@@ -61,13 +61,13 @@ Install the required native dependencies on macOS using Homebrew:
brew install \
libxmlsec1 \
libpq \
krb5 \
pkg-config \
uv \
postgresql \
redis \
node@24 \
golangci-lint
golangci-lint \
krb5
```
</TabItem>
@@ -76,7 +76,7 @@ golangci-lint
For Debian/Ubuntu-based distributions:
```shell
pip install uv && \
pip install uv
sudo apt-get install -y \
libgss-dev \
krb5-config \
@@ -182,13 +182,13 @@ Now that the backend and frontend have been set up and built, you can start auth
Start the server by running the following command in the same directory as your local authentik git repository:
```shell
make run-server # Starts authentik server
make run-server
```
Start the worker by running the following command in the same directory as your local authentik git repository:
```shell
make run-worker # Starts authentik worker
make run-worker
```
Both processes need to run to get a fully functioning authentik development environment.
@@ -214,7 +214,11 @@ When `AUTHENTIK_DEBUG` is set to `true` (the default for the development environ
## End-to-End (E2E) Setup
To run E2E tests, navigate to the `/tests/e2e` directory in your local copy of the authentik git repo, and start the services by running `docker compose up -d`.
Start the E2E test services with the following command:
```shell
docker compose -f tests/e2e/docker-compose.yml up -d
```
You can then view the Selenium Chrome browser via http://localhost:7900/ using the password: `secret`.
@@ -233,6 +237,7 @@ Ensure your code meets our quality standards by running:
1. **Code linting**:
```shell
make lint-fix
make lint
```
@@ -248,10 +253,16 @@ Ensure your code meets our quality standards by running:
make web
```
4. **Run tests**:
```shell
make test
```
You can run all these checks at once with:
```shell
make lint gen web
make all
```
### Submitting your changes

View File

@@ -1,27 +0,0 @@
---
title: Docs development environment
sidebar_label: Docs development
tags:
- development
- contributor
- docs
- docusaurus
---
If you want to only make changes to the documentation, you only need Node.js.
### Prerequisites
- Node.js (any recent version should work; we use 24.x to build)
- Make (again, any recent version should work)
:::info
Depending on platform, some native dependencies might be required. On macOS, run `brew install node@24`
:::
### Instructions
1. Clone the git repo from https://github.com/goauthentik/authentik
2. Run `make docs-install` to install the docs development dependencies
3. Run `make docs-watch` to start a development server to see and preview your changes
4. Finally when you're about to commit your changes, run `make docs` to run the linter and auto-formatter.

View File

@@ -0,0 +1,79 @@
---
title: Docs development environment
sidebar_label: Docs development
tags:
- development
- contributor
- docs
- docusaurus
---
import TabItem from "@theme/TabItem";
import Tabs from "@theme/Tabs";
If you want to only make changes to the documentation, you only need Node.js.
## Prerequisites
- [Node.js](https://nodejs.org/en) (24 or later)
- [Make](https://www.gnu.org/software/make/) (3 or later)
## 1. Install dependencies
<Tabs defaultValue="macOS">
<TabItem value="macOS">
Install the required dependencies on macOS using Homebrew:
```shell
brew install node@24
```
</TabItem>
<TabItem value="Linux">
[Download NodeJS version 24](https://nodejs.org/en/download/current) for your Linux distribution.
</TabItem>
<TabItem value="Windows">
We're currently seeking community input on running the docs in Windows. If you have experience with this setup, please consider contributing to this documentation.
</TabItem>
</Tabs>
## 2. Instructions
Clone the repo:
```shell
git clone https://github.com/goauthentik/authentik
```
Install the docs dev dependencies:
```shell
make docs-install
```
Start the development server:
```shell
make docs-watch
```
Run the linter and auto-formatter to prepare your changes to commit:
```shell
make docs
```
## 3. Submitting your changes
Once your changes pass all checks, you can submit a pull request through [GitHub](https://github.com/goauthentik/authentik/pulls). Be sure to:
- Provide a clear description of your changes
- Reference any related issues
- Follow our code style guidelines
Thank you for contributing to authentik!

View File

@@ -0,0 +1,55 @@
import styles from "./styles.module.css";
import { Redirect } from "@docusaurus/router";
import Translate from "@docusaurus/Translate";
import Heading from "@theme/Heading";
import type { Props } from "@theme/NotFound/Content";
import clsx from "clsx";
import React, { type ReactNode, useEffect, useState } from "react";
const DocsPrefix = "/docs";
export default function NotFoundContent({ className }: Props): ReactNode {
const [routeURL, setRouteURL] = useState<URL>();
useEffect(() => {
setRouteURL(new URL(window.location.href));
}, []);
if (typeof routeURL === "undefined") {
return null;
}
if (routeURL.pathname.startsWith(DocsPrefix)) {
routeURL.pathname = routeURL.pathname.slice(DocsPrefix.length);
const [, fullPathname = ""] = routeURL.href.split(window.location.origin);
return <Redirect to={fullPathname} />;
}
return (
<main className={clsx("container margin-vert--xl", styles.container, className)}>
<div className="row">
<div className="col col--8 col--offset-2">
<Heading as="h1" className="hero__title">
<Translate
id="theme.NotFound.title"
description="The title of the 404 page"
>
Page not found
</Translate>
</Heading>
<p>
<Translate
id="theme.NotFound.p1"
description="The first paragraph of the 404 page"
>
The page you are looking for may have moved or been deleted. Try using
the search bar above to find what you are looking for.
</Translate>
</p>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,4 @@
.container {
flex: 1 1 auto;
place-content: center;
}