mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 07:02:51 +02:00
Compare commits
11 Commits
developer-
...
a11y-wizar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feb0a93ef0 | ||
|
|
36bf0a762a | ||
|
|
61db0fa760 | ||
|
|
04f883c8da | ||
|
|
6d097feac0 | ||
|
|
6a2e07eb6a | ||
|
|
232f52b349 | ||
|
|
baeb892a22 | ||
|
|
810aa1cfea | ||
|
|
04a8357708 | ||
|
|
a8ecc9b530 |
263
.github/actions/cherry-pick/action.yml
vendored
Normal file
263
.github/actions/cherry-pick/action.yml
vendored
Normal 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
|
||||
@@ -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 }}
|
||||
2
.github/workflows/ci-main.yml
vendored
2
.github/workflows/ci-main.yml
vendored
@@ -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
25
.github/workflows/gh-cherry-pick.yml
vendored
Normal 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 }}
|
||||
3
.github/workflows/release-branch-off.yml
vendored
3
.github/workflows/release-branch-off.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/release-publish.yml
vendored
2
.github/workflows/release-publish.yml
vendored
@@ -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
|
||||
|
||||
19
Makefile
19
Makefile
@@ -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
2
web/.gitignore
vendored
@@ -25,6 +25,8 @@ lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
175
web/e2e/fixtures/FormFixture.ts
Normal file
175
web/e2e/fixtures/FormFixture.ts
Normal 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
|
||||
}
|
||||
30
web/e2e/fixtures/PageFixture.ts
Normal file
30
web/e2e/fixtures/PageFixture.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
42
web/e2e/fixtures/PointerFixture.ts
Normal file
42
web/e2e/fixtures/PointerFixture.ts
Normal 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()
|
||||
);
|
||||
};
|
||||
}
|
||||
104
web/e2e/fixtures/SessionFixture.ts
Normal file
104
web/e2e/fixtures/SessionFixture.ts
Normal 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
37
web/e2e/index.ts
Normal 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 }));
|
||||
},
|
||||
});
|
||||
13
web/e2e/selectors/types.ts
Normal file
13
web/e2e/selectors/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
|
||||
export type LocatorContext = Pick<
|
||||
Locator,
|
||||
| "locator"
|
||||
| "getByRole"
|
||||
| "getByTestId"
|
||||
| "getByText"
|
||||
| "getByLabel"
|
||||
| "getByAltText"
|
||||
| "getByTitle"
|
||||
| "getByPlaceholder"
|
||||
>;
|
||||
60
web/e2e/utils/generators.ts
Normal file
60
web/e2e/utils/generators.ts
Normal 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
102
web/logger/node.js
Normal 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
22
web/logger/transport.js
Normal 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
2003
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
],
|
||||
|
||||
18
web/packages/core/environment/load/node.js
Normal file
18
web/packages/core/environment/load/node.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
94
web/playwright.config.js
Normal 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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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>`;
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
140
web/src/standalone/loading/startup/startup.html
Normal file
140
web/src/standalone/loading/startup/startup.html
Normal 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>
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 don’t have access to any.")}
|
||||
</div>
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
217
web/test/browser/providers.test.ts
Normal file
217
web/test/browser/providers.test.ts
Normal 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
|
||||
});
|
||||
35
web/test/browser/session.test.ts
Normal file
35
web/test/browser/session.test.ts
Normal 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
87
web/test/lit/rendering.js
Normal 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
12
web/test/lit/setup.js
Normal 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());
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class UserLibraryPage extends Page {
|
||||
*/
|
||||
|
||||
public async pageHeader() {
|
||||
return await $('>>>h1[aria-level="1"]');
|
||||
return $(">>>header h1");
|
||||
}
|
||||
|
||||
public async goToAdmin() {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
23
web/types/node.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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!
|
||||
55
website/docusaurus-theme/theme/NotFound/Content/index.tsx
Normal file
55
website/docusaurus-theme/theme/NotFound/Content/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.container {
|
||||
flex: 1 1 auto;
|
||||
place-content: center;
|
||||
}
|
||||
Reference in New Issue
Block a user