mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 23:22:35 +02:00
Compare commits
4 Commits
web/docs/u
...
remote_deb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
264ad31f50 | ||
|
|
cced288ddb | ||
|
|
4aa323bc20 | ||
|
|
d6c0ae21de |
3
Makefile
3
Makefile
@@ -118,6 +118,9 @@ run-worker: ## Run the main authentik worker process
|
||||
run-worker-watch: ## Run the authentik worker, with auto reloading
|
||||
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
|
||||
|
||||
debug-attach: ## Attach pdb to a running authentik Python worker (PEP 768). PID=<pid> to pick; SUDO=1 on macOS.
|
||||
$(UV) run python scripts/debug_attach.py
|
||||
|
||||
core-i18n-extract:
|
||||
$(UV) run ak makemessages \
|
||||
--add-location file \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import faulthandler
|
||||
import os
|
||||
import random
|
||||
import signal
|
||||
@@ -76,6 +77,12 @@ def main(worker_id: int, socket_path: str):
|
||||
signal.signal(signal.SIGINT, immediate_shutdown)
|
||||
signal.signal(signal.SIGQUIT, immediate_shutdown)
|
||||
signal.signal(signal.SIGTERM, graceful_shutdown)
|
||||
# SIGUSR1 dumps every thread's traceback to stderr. Without this, the default
|
||||
# action is "terminate", which kills the worker (and trips the Rust supervisor).
|
||||
# Side-benefit: signal delivery wakes the eval loop, so `pdb -p` can attach to
|
||||
# an otherwise-idle worker parked in a C-level syscall.
|
||||
faulthandler.enable()
|
||||
faulthandler.register(signal.SIGUSR1)
|
||||
|
||||
random.seed()
|
||||
|
||||
@@ -97,7 +104,11 @@ def main(worker_id: int, socket_path: str):
|
||||
# Notify rust process that we are ready
|
||||
os.kill(os.getppid(), signal.SIGUSR2)
|
||||
|
||||
shutdown.wait()
|
||||
# Poll instead of waiting indefinitely so the main thread's eval loop ticks
|
||||
# periodically — PEP 768's debugger pending hook is serviced on the main
|
||||
# thread, and a permanent Event.wait() never returns to bytecode execution.
|
||||
while not shutdown.wait(timeout=1.0):
|
||||
pass
|
||||
|
||||
logger.info("Shutting down worker...")
|
||||
|
||||
|
||||
91
scripts/debug_attach.py
Normal file
91
scripts/debug_attach.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Attach pdb to a running authentik Python worker via PEP 768."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404 — needed to launch ps and pdb
|
||||
import sys
|
||||
|
||||
PS_BIN = shutil.which("ps") or "/bin/ps"
|
||||
|
||||
|
||||
def list_python_procs() -> list[tuple[int, int, str]]:
|
||||
# argv is fully controlled, no shell, ps path resolved at startup.
|
||||
out = subprocess.check_output([PS_BIN, "-eo", "pid,ppid,command"], text=True) # nosec B603
|
||||
procs: list[tuple[int, int, str]] = []
|
||||
for line in out.splitlines()[1:]:
|
||||
try:
|
||||
pid_s, ppid_s, cmd = line.split(None, 2)
|
||||
except ValueError:
|
||||
continue
|
||||
if not pid_s.isdigit() or not ppid_s.isdigit():
|
||||
continue
|
||||
procs.append((int(pid_s), int(ppid_s), cmd))
|
||||
return procs
|
||||
|
||||
|
||||
def find_targets() -> list[tuple[int, str]]:
|
||||
# Match any authentik Python process: dev_server / runserver via manage.py,
|
||||
# gunicorn, dramatiq, or the worker_process supervisor. Go/Rust supervisors
|
||||
# and the `uv run` / shell wrappers don't match these patterns.
|
||||
needles = (
|
||||
"manage.py",
|
||||
"manage dev_server",
|
||||
"manage runserver",
|
||||
"gunicorn",
|
||||
"dramatiq",
|
||||
"lifecycle.worker_process",
|
||||
"lifecycle/worker_process",
|
||||
)
|
||||
matches = [(p, pp, c) for p, pp, c in list_python_procs() if any(n in c for n in needles)]
|
||||
matched_pids = {p for p, _, _ in matches}
|
||||
parents_of_matches = {pp for _, pp, _ in matches if pp in matched_pids}
|
||||
# A leaf is a match that isn't itself the parent of another match — this
|
||||
# picks the dev_server reloader child or the gunicorn worker, and still
|
||||
# includes single-process workers (which trivially have no child match).
|
||||
leaves = [(p, c) for p, _, c in matches if p not in parents_of_matches]
|
||||
return leaves
|
||||
|
||||
|
||||
def attach(pid: int) -> int:
|
||||
use_sudo = os.environ.get("SUDO") == "1"
|
||||
cmd = [sys.executable, "-m", "pdb", "-p", str(pid)]
|
||||
if use_sudo:
|
||||
cmd = ["sudo", "-E", *cmd]
|
||||
print(f"attaching pdb to pid {pid} (Ctrl-D or `quit` to detach)", file=sys.stderr)
|
||||
# cmd is built from sys.executable plus a digits-only PID.
|
||||
rc = subprocess.call(cmd) # nosec B603
|
||||
if rc != 0 and not use_sudo and sys.platform == "darwin":
|
||||
print(
|
||||
"\nattach failed. On macOS task_for_pid is restricted; "
|
||||
f"retry with: SUDO=1 make debug-attach PID={pid}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env_pid = os.environ.get("PID")
|
||||
if env_pid:
|
||||
if not env_pid.isdigit():
|
||||
print(f"PID={env_pid!r} is not numeric", file=sys.stderr)
|
||||
return 2
|
||||
return attach(int(env_pid))
|
||||
|
||||
targets = find_targets()
|
||||
if not targets:
|
||||
print(
|
||||
"no gunicorn/dramatiq Python workers found — is `make run-server` "
|
||||
"or `make run-worker` running?",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if len(targets) > 1:
|
||||
print("multiple worker candidates — pick one with PID=<pid>:", file=sys.stderr)
|
||||
for pid, cmd in targets:
|
||||
print(f" {pid}\t{cmd[:120]}", file=sys.stderr)
|
||||
return 1
|
||||
return attach(targets[0][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
409
web/README.md
409
web/README.md
@@ -1,303 +1,112 @@
|
||||
# The authentik WebUI
|
||||
|
||||
The authetik WebUI is the default UI for the authentik Single Sign-on (SSO) server. It consists of
|
||||
three primary applications:
|
||||
|
||||
- Flow: The transaction-driven, customizable interface for logging in and all other workflow
|
||||
activities
|
||||
- User: The user's library of applications to which they have access, and user settings such as
|
||||
configuring their MFA and email address, viewing their current sessions, and more
|
||||
- Admin: The system administration tool for defining applications, providers, policies, and
|
||||
everything else
|
||||
|
||||
Each of these is a [thin client] application around data objects provided by and transactions
|
||||
available with the authentik SSO server. Business logic and validation is provided by the server.
|
||||
|
||||
The authentik SSO server is written in Python and Django.
|
||||
|
||||
> - [thin client](https://en.wikipedia.org/wiki/Thin_client): In this case, we mean "a front end to
|
||||
> show the data where the server does all the heavy lifting."
|
||||
|
||||
## Project setup
|
||||
|
||||
If you have cloned the authentik repository, the [developer docs] are where you go to perform the
|
||||
initial set-up and install. This sequence will get you up and running in the usual course of events.
|
||||
|
||||
```
|
||||
$ make install
|
||||
$ make gen-dev-config
|
||||
$ make migrate
|
||||
$ make run-server
|
||||
$ make run-worker
|
||||
```
|
||||
|
||||
We recommend that you run `run-server` and `run-worker` in different terminals or different sessions
|
||||
under tmux or screen or a similar terminal multiplexer.
|
||||
|
||||
The WebUI runs in this folder (`./web` under the project root). You can put the WebUI into hot
|
||||
reload by running, from this folder,
|
||||
|
||||
```
|
||||
$ npm run watch
|
||||
```
|
||||
|
||||
## Front-End Architecture
|
||||
|
||||
### The Django side
|
||||
|
||||
The authentik web-based applications are delivered via a Django server. The server delivers an HTML
|
||||
template that executes a sequence of startup operations:
|
||||
|
||||
- Assigns the language code and `data-theme="light|dark"` settings to the `html` tag. Assigns the
|
||||
favicon links. Creates a `window.authentik` object and assigns a variety of site-wide
|
||||
configuration details to it. Probes the a priority decision list of sources of the user's
|
||||
light/dark preferences and assigns that to the `Document.dataset`
|
||||
- Begins loading the interface root bundle. The script is of `type="module"`; it will not be
|
||||
executed until the initial HTML has been completely parsed.
|
||||
- Loads the site-wide standard CSS.
|
||||
- Injects any CSS overrides specified in the customer's `brand` settings
|
||||
- Loads any necessary JavaScript polyfills
|
||||
- Dispatches any initial messages to the notification handler
|
||||
- Sets any custom `<meta>` settings specified by the server
|
||||
- Provides the initial HTML scaffolding to launch an interface.
|
||||
|
||||
The interface code is mostly the core web component and its responsibilities. This code will be
|
||||
hydrated when the interface root bundle in the second step above is executed and the components are
|
||||
registered with the browser.
|
||||
|
||||
### The Flow interface
|
||||
|
||||
The Flow interface has three subsystems:
|
||||
|
||||
- The Locale Selector: `<ak-locale-select>`
|
||||
- The Flow Inspector: `<ak-flow-inspector>`
|
||||
- The Flow Executor: `<ak-flow-executor>`
|
||||
|
||||
The Flow interface is a single-page application. The Locale Selector and the Inspector are
|
||||
independent buttons that persist on the web page for the duration of a Flow. Either can be disabled
|
||||
and hidden by admin preference. The Locale Selector allows the user to select an alternative locale
|
||||
in which to display text and labels. The [Flow
|
||||
Inspector](https://docs.goauthentik.io/add-secure-apps/flows-stages/flow/inspector/), when enabled,
|
||||
can query the server and then display details about the state of a flow: the accumulated context of
|
||||
the current flow, existing error messages, and expected next steps; it is present to assist with
|
||||
debugging.
|
||||
|
||||
The Executor is the heart of the system. It executes Flows.
|
||||
|
||||
A _Flow_ in authentik is the workflow that accomplishes a specific SSO-oriented task such as logging
|
||||
in, logging out, or enrolling as a new user, among others.
|
||||
|
||||
The Executor starts by examining the current URL for the `flowSlug`, and sends a request for a
|
||||
_Challenge_ to the server. Upon the response, the Executor loads the corresponding _Stage_: the UI
|
||||
component responsible for showing the challenge to the user. When the user performs the requested
|
||||
action the input is sent to the server, which issues a new Challenge. This Challenge may be the same
|
||||
one with error messages, or the next one in the workflow. This process repeats until the user
|
||||
reaches the end of the Flow, at which point the task is complete or failed.
|
||||
|
||||
The architecture for the Executor is straightforward:
|
||||
|
||||
- The HTML Document
|
||||
- The Locale Selector
|
||||
- The Inspector
|
||||
- The Executor
|
||||
- The current Stage
|
||||
|
||||
A Stage may have interior stages or components. The Identification Stage is the most complex of our
|
||||
stages. It usually shows the Username field, and in some configurations it _can_ show the Password
|
||||
field; in that case, the password component exists to allow the user to "show password". It may also
|
||||
host the Captcha and Passkey stages within, to complete the initial task of determining and
|
||||
validating a user's identity.
|
||||
|
||||
### User and Admin Interfaces
|
||||
|
||||
The architecture of these interfaces is more complex. In both cases, the user is assumed to have
|
||||
logged in and so is said to have a _Session_. The architecture is structured:
|
||||
|
||||
- The HTML Document
|
||||
- The Interface
|
||||
- License: a context handler for the site's enterprise license status
|
||||
- Session: a context handler for the user's current session. This mostly the `user` identity
|
||||
- Version: a context handler for the current version of authentik
|
||||
- Notifications: a context handler for outstanding messages sent from the server to the user
|
||||
- Capabilities: a list of features that the current user may use. List includes "can save
|
||||
reports," "can use debugging feature," "can use enterprise features."
|
||||
- The Application:
|
||||
- Header
|
||||
- Sidebar
|
||||
- Router
|
||||
- CRUD interfaces to features of the system:
|
||||
- Dashboard
|
||||
- Logs
|
||||
- Configurations
|
||||
- Flows, Stages & Policies
|
||||
- Users & Group
|
||||
- IDP Sources
|
||||
- Everything else!
|
||||
|
||||
### Miscellaneous interfaces
|
||||
|
||||
There are three miscellaneous interfaces:
|
||||
|
||||
#### API browser
|
||||
|
||||
A single page application that loads our schema and allows the user to experiment with it. Uses the
|
||||
[RapiDoc](https://rapidocweb.com/) app.
|
||||
|
||||
#### Loading
|
||||
|
||||
The Django application is wrapped in a proxy server for caching and performance; while it is in
|
||||
start-up mode, the proxy serves this page, which just says "The application is loading" with a
|
||||
pretty animation.
|
||||
|
||||
#### SFE: Simplified Flow Executor
|
||||
|
||||
The SFE is a limited version of the Flow Executor written to use [jQuery](https://jquery.com/). It
|
||||
supports only login operations, and is meant for places where the login is embedded in an Office365
|
||||
or MicrosoftTeams settings, as those use Trident (Internet Explorer) for their web-based login.
|
||||
|
||||
## Front-end foundations
|
||||
|
||||
### CSS
|
||||
|
||||
Our current CSS is provided by [Patternfly 4](https://v4-archive.patternfly.org/v4/). There are two
|
||||
different layers of CSS.
|
||||
|
||||
The first is the Global CSS that appears in the `<head>`. This defines the basic look: theme,
|
||||
start-up, reset, and fonts. It also provides the [CSS Custom
|
||||
Properties](https://docs.goauthentik.io/brands/custom-css/) that control the look and feel of the
|
||||
rest of an Interface.
|
||||
|
||||
The second is per-component CSS. This is linked into each component using [Adopted
|
||||
Stylesheets](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets).
|
||||
|
||||
> This recipe has led to some significant awkwardness. Information from the outside does not pierce
|
||||
> the shadowDOM, so Patternfly-Base is linked into every component just to provide the box model,
|
||||
> reset, basic functionality, and behavioral modifiers. The interior of our components is cluttered
|
||||
> with lots of patternfly classes.
|
||||
|
||||
### Elements
|
||||
|
||||
Elements are custom web components that authentik has written that supply advanced behaviors to
|
||||
common operations, as well as API-independent complex components such as rich drop-downs, dual-pane
|
||||
selectors, toggles, switches, and wizards. At least, that's the idea. We are still untangling.
|
||||
|
||||
### Components
|
||||
|
||||
Components are custom web components that authentik has written that are API-aware and that supply
|
||||
business logic to perform validation, permissioning, and selective display.
|
||||
|
||||
## Adding a new feature (developer's guide)
|
||||
|
||||
As a thin client, the primary task will either be adding a new CRUD vertical or extending and
|
||||
enhancing an existing one. (If the elements, components, API, and so on represent the horizontal
|
||||
layers of an application, a single CRUD task is the "vertical slice" through these.) Our Django
|
||||
application presents collections of objects from which the user may pick one to view, update, or
|
||||
delete.
|
||||
|
||||
The web component in `./elements/table` is used to display, well, tables of components. A new
|
||||
feature begins by inheriting the `Table` class and providing two things: the API call to retrieve
|
||||
the objects, and a method describing a row for the table. This is the retrieval for our Role-Based
|
||||
Access Controls (RBAC).
|
||||
|
||||
```
|
||||
async apiEndpoint(): Promise<PaginatedResponse<Role>> {
|
||||
return new RbacApi(DEFAULT_CONFIG).rbacRolesList({
|
||||
...(await this.defaultEndpointConfig()),
|
||||
managedIsnull: this.hideManaged ? true : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The complete list of APIs available can be found in `node_modules/@goauthentik/api/src/apis`.
|
||||
|
||||
A row returns an array of cells:
|
||||
|
||||
```
|
||||
row(item: Role): SlottedTemplateResult[] {
|
||||
return [
|
||||
html`<a href="#/identity/roles/${item.pk}">${item.name}</a>`,
|
||||
html`<div>
|
||||
<ak-forms-modal>
|
||||
<span slot="submit">${msg("Update")}</span>
|
||||
<span slot="header">${msg("Update Role")}</span>
|
||||
<ak-role-form slot="form" .instancePk=${item.pk}> </ak-role-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-plain">
|
||||
<pf-tooltip position="top" content=${msg("Edit")}>
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
This example shows the use of the `modal` dialogue to show the "update role" form. Deciding to use
|
||||
a modal or to move to a different page is a matter of taste, but mostly rests on how large the form
|
||||
is. If it's likely to have internal scrolling, opt for a separate page.
|
||||
|
||||
For complex objects that have a lot of detail or subsidiary lists of features (such as Flows),
|
||||
provide a separate View page for each one. We have a specified display standard encapsulated in our
|
||||
`DictionaryList` component.
|
||||
|
||||
Creation and Updating are handled using the web component parent in `./elements/forms`. Like
|
||||
tables, a child component inherits and extends the Form class, providing three features: how to
|
||||
_retrieve_ the object, how to _send_ the object, and what to ask for. (RBAC is small enough, it's
|
||||
useful as an example):
|
||||
|
||||
```
|
||||
loadInstance(pk: string): Promise<Role> {
|
||||
return new RbacApi(DEFAULT_CONFIG).rbacRolesRetrieve({
|
||||
uuid: pk,
|
||||
});
|
||||
}
|
||||
|
||||
async send(data: Role): Promise<Role> {
|
||||
if (this.instance?.pk) {
|
||||
return new RbacApi(DEFAULT_CONFIG).rbacRolesPartialUpdate({
|
||||
uuid: this.instance.pk,
|
||||
patchedRoleRequest: data,
|
||||
});
|
||||
}
|
||||
return new RbacApi(DEFAULT_CONFIG).rbacRolesCreate({
|
||||
roleRequest: data,
|
||||
});
|
||||
}
|
||||
|
||||
protected override renderForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${msg("Name")} required name="name">
|
||||
<input
|
||||
type="text"
|
||||
value="${ifDefined(this.instance?.name)}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The `send` shows two different modes: If the existing instance has an identity, this is an update;
|
||||
otherwise it's a creation request.
|
||||
|
||||
These are _simple_ examples, naturally, and our application can get much more complicated. The
|
||||
`./admin/flows` vertical is one of the most complex, including:
|
||||
|
||||
- A per-flow view page with a [Mermaid](https://mermaid.js.org/) diagram to show a Flow's Stages
|
||||
- A sub-table of the Flow's Policies, with the ability to edit each Policy or its Bindings
|
||||
- A sub-table of the Flow's Stages with the ability to edit each Stage or a Stage's Binding directly
|
||||
- A sub-table of the Flow's Permissions
|
||||
|
||||
## Choosing To Use A Custom Component (developer's guide)
|
||||
|
||||
Some of our server-side objects come with lists. When editing a list, we suggest:
|
||||
|
||||
- If it's a simple list and there's only one choice, use `<select>`
|
||||
- If it's from the server and it's possible there are more than 100 items, use SearchSelect. It
|
||||
has features for showing complex list objects and narrowing down search items.
|
||||
- If the user can select multiple choices, use DualSelect
|
||||
# authentik WebUI
|
||||
|
||||
This is the default UI for the authentik server. The documentation is going to be a little sparse
|
||||
for awhile, but at least let's get started.
|
||||
|
||||
# The Theory of the authentik UI
|
||||
|
||||
In Peter Naur's 1985 essay [Programming as Theory
|
||||
Building](https://pages.cs.wisc.edu/~remzi/Naur.pdf), programming is described as creating a mental
|
||||
model of how a program _should_ run, then writing the code to test if the program _can_ run that
|
||||
way.
|
||||
|
||||
The mental model for the authentik UI is straightforward. There are five "applications" within the
|
||||
UI, each with its own base URL, router, and responsibilities, and each application needs as many as
|
||||
three contexts in which to run.
|
||||
|
||||
The three contexts corresponds to objects in the API's `model` section, so let's use those names.
|
||||
|
||||
- The root `Config`. The root configuration object of the server, containing mostly caching and
|
||||
error reporting information. This is misleading, however; the `Config` object contains some user
|
||||
information, specifically a list of permissions the current user (or "no user") has.
|
||||
- The root `CurrentTenant`. This describes the `Brand` information UIs should use, such as themes,
|
||||
logos, favicon, and specific default flows for logging in, logging out, and recovering a user
|
||||
password.
|
||||
- The current `SessionUser`, the person logged in: username, display name, and various states.
|
||||
(Note: the authentik server permits administrators to "impersonate" any other user in order to
|
||||
debug their authentication experience. If impersonation is active, the `user` field reflects that
|
||||
user, but it also includes a field, `original`, with the administrator's information.)
|
||||
|
||||
(There is a fourth context object, Version, but its use is limited to displaying version information
|
||||
and checking for upgrades. Just be aware that you will see it, but you will probably never interact
|
||||
with it.)
|
||||
|
||||
There are five applications. Two (`loading` and `api-browser`) are trivial applications whose
|
||||
insides are provided by third-party libraries (Patternfly and Rapidoc, respectively). The other
|
||||
three are actual applications. The descriptions below are wholly from the view of the user's
|
||||
experience:
|
||||
|
||||
- `Flow`: From a given URL, displays a form that requests information from the user to accomplish a
|
||||
task. Some tasks require the user to be logged in, but many (such as logging in itself!)
|
||||
obviously do not.
|
||||
- `User`: Provides the user with access to the applications they can access, plus a few user
|
||||
settings.
|
||||
- `Admin`: Provides someone with super-user permissions access to the administrative functions of
|
||||
the authentik server.
|
||||
|
||||
**Mental Model**
|
||||
|
||||
- Upon initialization, _every_ authentik UI application fetches `Config` and `CurrentTenant`. `User`
|
||||
and `Admin` will also attempt to load the `SessionUser`; if there is none, the user is kicked out
|
||||
to the `Flow` for logging into authentik itself.
|
||||
- `Config`, `CurrentTenant`, and `SessionUser`, are provided by the `@goauthentik/api` application,
|
||||
not by the codebase under `./web`. (Where you are now).
|
||||
- `Flow`, `User`, and `Admin` are all called `Interfaces` and are found in
|
||||
`./web/src/flow/FlowInterface`, `./web/src/user/UserInterface`, `./web/src/admin/AdminInterface`,
|
||||
respectively.
|
||||
|
||||
Inside each of these you will find, in a hierarchal order:
|
||||
|
||||
- The context layer described above
|
||||
- A theme managing layer
|
||||
- The orchestration layer:
|
||||
- web socket handler for server-generated events
|
||||
- The router
|
||||
- Individual routes for each vertical slice and its relationship to other objects:
|
||||
|
||||
Each slice corresponds to an object table on the server, and each slice _usually_ consists of the
|
||||
following:
|
||||
|
||||
- A paginated collection display, usually using the `Table` foundation (found in
|
||||
`./web/src/elements/Table`)
|
||||
- The ability to view an individual object from the collection, which you may be able to:
|
||||
- Edit
|
||||
- Delete
|
||||
- A form for creating a new object
|
||||
- Tabs showing that object's relationship to other objects
|
||||
- Interactive elements for changing or deleting those relationships, or creating new ones.
|
||||
- The ability to create new objects with which to have that relationship, if they're not part of
|
||||
the core objects (such as User->MFA authenticator apps, since the latter is not a "core" object
|
||||
and has no tab of its own).
|
||||
|
||||
We are still a bit "all over the place" with respect to sub-units and common units; there are
|
||||
folders `common`, `elements`, and `components`, and ideally they would be:
|
||||
|
||||
- `common`: non-UI related libraries all of our applications need
|
||||
- `elements`: UI elements shared among multiple applications that do not need context
|
||||
- `components`: UI elements shared among multiple that use one or more context
|
||||
|
||||
... but at the moment there are some context-sensitive elements, and some UI-related stuff in
|
||||
`common`.
|
||||
|
||||
# Comments
|
||||
|
||||
**NOTE:** The comments in this section are for specific changes to this repository that cannot be
|
||||
reliably documented any other way. For the most part, they contain comments related to custom
|
||||
settings in JSON files, which do not support comments.
|
||||
|
||||
- `tsconfig.json`:
|
||||
- `compilerOptions.useDefineForClassFields: false` is required to make TSC use the "classic" form
|
||||
of field definition when compiling class definitions. Storybook does not handle the ESNext
|
||||
proposed definition mechanism (yet).
|
||||
- `compilerOptions.plugins.ts-lit-plugin.rules.no-unknown-tag-name: "off"`: required to support
|
||||
rapidoc, which exports its tag late.
|
||||
- `compilerOptions.plugins.ts-lit-plugin.rules.no-missing-import: "off"`: lit-analyzer currently
|
||||
does not support path aliases very well, and cannot find the definition files associated with
|
||||
imports using them.
|
||||
- `compilerOptions.plugins.ts-lit-plugin.rules.no-incompatible-type-binding: "warn"`: lit-analyzer
|
||||
does not support generics well when parsing a subtype of `HTMLElement`. As a result, this threw
|
||||
too many errors to be supportable.
|
||||
|
||||
### License
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# 01 Foundations: Language
|
||||
|
||||
Date: 2026-05-01 (May 1st, 2026)
|
||||
|
||||
## The web application
|
||||
|
||||
The authentik web-based front-end is written in Typescript. We are currently targeting Typescript
|
||||
7.0, aka "TSGO," for its speed and compatibility. We chose Typescript because our experience has
|
||||
been that the type system, when used reliably, can prevent a wide class of errors, especially when
|
||||
negotiating with the authentik API as generated by OpenAPI.
|
||||
|
||||
- `mode: strict` is non-negotiable.
|
||||
- `useDefineForClassFields` is required for Lit decorator compatibility. See the Lit documentation
|
||||
[Typescript class fields for reactive
|
||||
properties](https://lit.dev/docs/components/properties/#:~:text=Set%20the%20useDefineForClassFields%20compiler%20option%20to%20false)
|
||||
for details.
|
||||
|
||||
## Tooling
|
||||
|
||||
Most of our internal tooling in the `./web/scripts` folder is written in JavaScript, not Typescript,
|
||||
to avoid the chicken-and-egg problem of needing build scripts to build build scripts. To facilitate
|
||||
checking, we enable `checkJs` and [jsdoc supported
|
||||
types](https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#param-and-returns) in
|
||||
our tooling and script files, and we check them rigorously.
|
||||
|
||||
## Guidance:
|
||||
|
||||
Whenever tempted to use `any`, use `unknown` and a [type
|
||||
predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
|
||||
(sometimes called a "type guard") instead.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 02 Foundations: Build Tools
|
||||
|
||||
Date: 2026-05-01 (May 1st, 2026)
|
||||
|
||||
## Esbuild and TSGo
|
||||
|
||||
In 2024, the web UI used Rollup and TSC as its primary build tools. Building the entire UI for
|
||||
release took as many as three minutes.
|
||||
|
||||
ESBuild can both produce running Javascript from Typescript, and perform all of the bundling
|
||||
required to support the authentik WebUI. Switching to ESBuild reduced build time to 5 _seconds_. TSC
|
||||
has been relegated to the `no-emit` strategy of type-checking but not code-producing.
|
||||
|
||||
One complication in our code is that our web component foundation, Lit, has an awkward
|
||||
CSS-in-Javascript format incompatible with the build tools intended to support React, and the
|
||||
ESBuild plug-in to handle it is custom.
|
||||
|
||||
As of this writing, Typescript 7.0, aka "TSGo," is currently in beta. When it is released, we expect
|
||||
to both reassess this strategy and examine alternative build strategies. We prefer to hew as close
|
||||
to the Typescript standard as possible, and the standard is set by the Typescript team.
|
||||
|
||||
## Wireit
|
||||
|
||||
We have chosen to use Wireit because it provides a finer degree of control over build order and
|
||||
provides a caching strategy. This significantly speeds up rebuilding during development versus using
|
||||
NPM's own builds. Use Wireit _only_ when you need the cache or dependency order to be strict; for
|
||||
baseline builds, prefer writing directly into the `scripts` section of `package.json`.
|
||||
@@ -1,20 +0,0 @@
|
||||
# 01 Foundations: Workspaces
|
||||
|
||||
Date: 2026-05-02 (May 2st, 2026)
|
||||
|
||||
## Workspaces
|
||||
|
||||
In order to promote the use and development of a product by the widest community possible, we
|
||||
default to using NPM workspaces, since it is the most common too possible.
|
||||
|
||||
Provide a separate workspace when:
|
||||
|
||||
1. The project is support that applies across multiple other workspaces, rather than being a part of
|
||||
an application directly. `./packages/core` is the example.
|
||||
2. The project is a polyfill or library that is needed across all the applications supported by the
|
||||
front-end. `./packages/formdata-polyfill` is the example.
|
||||
|
||||
3. The project is an application that has radically different requirements from the standard set of
|
||||
applications. `./packages/sfe` exists to support only the Login Flow with the Internet Explorer
|
||||
11-based rendering engine, which is still embedded in some older Microsoft products we cannot
|
||||
afford to ignore.
|
||||
@@ -1,12 +0,0 @@
|
||||
# 01 Foundations: Import Strategies
|
||||
|
||||
Date: 2026-05-02 (May 2st, 2026)
|
||||
|
||||
## Import Strategies
|
||||
|
||||
`package.json` defines a large number of import paths that reach into the `src` folder. We use
|
||||
NodeJS subpaths prefixed with `#`, such as `#fonts`, `#elements`, or `#flow` to isolate subsections
|
||||
of the frontend. This strategy is intended to facilitate directory restructuring without having to
|
||||
do mass search-and-replace ops, and as a precursor to further mono-repo-ifying the codebase.
|
||||
|
||||
We recommend using barrel files only to export the intended API of a defined subsection.
|
||||
@@ -1,69 +0,0 @@
|
||||
# 01 Foundations: Code Quality
|
||||
|
||||
Date: 2026-05-02 (May 2st, 2026)
|
||||
|
||||
## Code Linting
|
||||
|
||||
We _like_ our guardrails. We use ESLint with as many plug-ins as we can reasonably stuff into it for
|
||||
our checks, such as `eslint-plugin-lit` and `eslint-plugin-wc`, plus `lit-analyzer`.
|
||||
|
||||
## Code Formatting
|
||||
|
||||
We use `prettier` to enforce a coding style and to catch some fundamental syntax errors. The current
|
||||
`prettier` configuration correctly formats Lit's HTML-in-JS and CSS-it-JS use cases, as well as all
|
||||
the Typescript we can throw at it.
|
||||
|
||||
## Lockfile
|
||||
|
||||
We have a custom script in `./scripts/lint-lockfile.sh` that checks to ensure that every packages as
|
||||
a resolved hash.
|
||||
|
||||
## Type Checking
|
||||
|
||||
Although we use ESBuild to convert and bundle our Typescript into JavaScript, we use the stock
|
||||
Typescript compiler, `tsc`, to check our types. We maintain a default configuration with `use
|
||||
strict`.
|
||||
|
||||
## Testing
|
||||
|
||||
We do have tests, but they are primitive. We are very much in a move-fast and try hard not to break
|
||||
things. We strongly recommend that every PR include a description of how a peer would test the
|
||||
product manually to validate that it does what the PR claims it does.
|
||||
|
||||
Adding to the library of end-to-end tests is a critical mission.
|
||||
|
||||
## Your eyes, and the eyes of your peers
|
||||
|
||||
For all that we do like our guardrails, nothing surpasses peer review.
|
||||
|
||||
## AI Review
|
||||
|
||||
We have had mixed results using AI tools such as Claude and Copilot to vet our code. Claude,
|
||||
especially, can be very good at pointing out shortcomings and missed opportunities in a pull
|
||||
request, but it can also generate a lot of false positives or trivial issues. We recommend reading
|
||||
AI reviews with caution.
|
||||
|
||||
## AI Review Strategy
|
||||
|
||||
That said, this is the current template for a code review prompt. Start with the _target_ branch,
|
||||
then download the patch file into the project root. You can easily download the patch file by
|
||||
navigating to the Github PR and appending `.patch` to it, for example:
|
||||
`https://github.com/goauthentik/authentik/pull/21868.patch`
|
||||
|
||||
We use this template:
|
||||
|
||||
> Keep the tone neutral-professional-skeptical, the voice of an expert. Avoid excessive enthusiasm.
|
||||
> This is the root folder for the authentik single sign-on server.
|
||||
>
|
||||
> Read the patch file `./21868.patch`. This [community-provided] patch [describe the patch here in
|
||||
> your own words, using only one or two sentences].
|
||||
>
|
||||
> Task 1: Provide a high-level summary of the effect of applying `./21868.patch` Point out any
|
||||
> shortcomings or security considerations.
|
||||
>
|
||||
> Task2: If no tests are provided in the patch, describe how these changes could be tested.
|
||||
|
||||
Edit the patch number, add or remove "community-provided" as needed, and include your best
|
||||
understanding of what the patch claims to do in the second paragraph. Having a strong template that
|
||||
you hand-edit before running seems to work much better than using a generic template in a Claude
|
||||
skill.
|
||||
@@ -77,6 +77,8 @@ export class FormFixture extends PageFixture {
|
||||
|
||||
/**
|
||||
* Search for a row containing the given text.
|
||||
*
|
||||
* @returns A locator for the row entry matching the query.
|
||||
*/
|
||||
public search = async (
|
||||
query: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
|
||||
import { PageFixture, PageFixtureInit } from "#e2e/fixtures/PageFixture";
|
||||
|
||||
import { expect, Page } from "@playwright/test";
|
||||
|
||||
export const GOOD_USERNAME = "test-admin@goauthentik.io";
|
||||
export const GOOD_PASSWORD = "test-runner";
|
||||
|
||||
@@ -11,6 +13,8 @@ export interface LoginInit {
|
||||
username?: string;
|
||||
password?: string;
|
||||
to?: URL | string;
|
||||
rememberMe?: boolean;
|
||||
page?: Page;
|
||||
}
|
||||
|
||||
export interface SessionFixtureInit extends PageFixtureInit {
|
||||
@@ -36,6 +40,10 @@ export class SessionFixture extends PageFixture {
|
||||
public $passwordStage = this.page.locator("ak-stage-password");
|
||||
public $passwordField = this.page.getByLabel("Password");
|
||||
|
||||
public $rememberMeCheckbox = this.page.getByRole("checkbox", {
|
||||
name: "Remember me on this device",
|
||||
});
|
||||
|
||||
/**
|
||||
* The button to submit the the login flow,
|
||||
* typically redirecting to the authenticated interface.
|
||||
@@ -66,19 +74,45 @@ export class SessionFixture extends PageFixture {
|
||||
/**
|
||||
* Log into the application.
|
||||
*/
|
||||
public async login({
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
}: LoginInit = {}) {
|
||||
public async login(
|
||||
{
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
rememberMe,
|
||||
}: LoginInit = {},
|
||||
page = this.page,
|
||||
): Promise<void> {
|
||||
this.logger.info("Logging in...");
|
||||
|
||||
const initialURL = new URL(this.page.url());
|
||||
const initialURL = new URL(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 page.goto(to.toString());
|
||||
}
|
||||
|
||||
if (typeof rememberMe === "boolean") {
|
||||
const rememberMeCheckboxVisible = await this.$rememberMeCheckbox.isVisible();
|
||||
|
||||
if (rememberMeCheckboxVisible) {
|
||||
if (rememberMe) {
|
||||
await this.$rememberMeCheckbox.check();
|
||||
|
||||
await expect(
|
||||
this.$rememberMeCheckbox,
|
||||
"Remember me checkbox is checked",
|
||||
).toBeChecked();
|
||||
} else {
|
||||
await this.$rememberMeCheckbox.uncheck();
|
||||
|
||||
await expect(
|
||||
this.$rememberMeCheckbox,
|
||||
"Remember me checkbox is unchecked",
|
||||
).not.toBeChecked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.$usernameField.fill(username);
|
||||
@@ -102,7 +136,7 @@ export class SessionFixture extends PageFixture {
|
||||
|
||||
//#region Navigation
|
||||
|
||||
public async toLoginPage() {
|
||||
await this.page.goto(SessionFixture.pathname);
|
||||
public async toLoginPage(page: Page = this.page) {
|
||||
await page.goto(SessionFixture.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,30 @@ import "#elements/buttons/SpinnerButton/index";
|
||||
import "#elements/forms/HorizontalFormElement";
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
import { PFSize } from "#common/enums";
|
||||
|
||||
import { Form } from "#elements/forms/Form";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
import { FocusTarget } from "#elements/utils/focus";
|
||||
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { CoreApi, UserPasswordSetRequest } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@customElement("ak-user-password-form")
|
||||
export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
public override submitLabel = msg("Set Password");
|
||||
public static shadowRootOptions: ShadowRootInit = {
|
||||
...Form.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public static override verboseName = msg("Password");
|
||||
public static override verboseNamePlural = msg("Passwords");
|
||||
public static override submittingVerb = msg("Setting");
|
||||
|
||||
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
|
||||
|
||||
@@ -23,6 +33,9 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
|
||||
//#region Properties
|
||||
|
||||
public override submitLabel = msg("Set Password");
|
||||
public override successMessage = msg("Successfully updated password.");
|
||||
|
||||
@property({ type: Number })
|
||||
public instancePk?: number;
|
||||
|
||||
@@ -30,13 +43,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
public label = msg("New Password");
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder = msg("New Password");
|
||||
public placeholder = msg("Type a new password...");
|
||||
|
||||
@property({ type: String })
|
||||
public username?: string;
|
||||
@property({ type: String, useDefault: true })
|
||||
public username: string | null = null;
|
||||
|
||||
@property({ type: String })
|
||||
public email?: string;
|
||||
@property({ type: String, useDefault: true })
|
||||
public email: string | null = null;
|
||||
|
||||
public override size = PFSize.Medium;
|
||||
|
||||
/**
|
||||
* The autocomplete attribute to use for the password field.
|
||||
@@ -50,17 +65,15 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
|
||||
//#endregion
|
||||
|
||||
public override getSuccessMessage(): string {
|
||||
return msg("Successfully updated password.");
|
||||
}
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("focus", this.autofocusTarget.toEventListener());
|
||||
}
|
||||
|
||||
public override firstUpdated(): void {
|
||||
this.focus();
|
||||
requestAnimationFrame(() => {
|
||||
this.focus();
|
||||
});
|
||||
}
|
||||
|
||||
protected override async send(data: UserPasswordSetRequest): Promise<void> {
|
||||
@@ -94,17 +107,26 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
/>`
|
||||
: nothing}
|
||||
|
||||
<ak-form-element-horizontal label=${this.label} required name="password">
|
||||
<ak-form-element-horizontal required name="password">
|
||||
${AKLabel(
|
||||
{
|
||||
slot: "label",
|
||||
className: "pf-c-form__group-label",
|
||||
htmlFor: "password",
|
||||
required: true,
|
||||
},
|
||||
this.label,
|
||||
)}
|
||||
<input
|
||||
autofocus
|
||||
${this.autofocusTarget.toRef()}
|
||||
id="password"
|
||||
type="password"
|
||||
value=""
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
placeholder=${ifDefined(this.placeholder || this.label)}
|
||||
aria-label=${this.label}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
placeholder=${ifPresent(this.placeholder || this.label)}
|
||||
autocomplete=${ifPresent(this.autocomplete)}
|
||||
/>
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
|
||||
140
web/src/common/storage.ts
Normal file
140
web/src/common/storage.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file Storage utilities.
|
||||
*/
|
||||
|
||||
import { ConsoleLogger } from "#logger/browser";
|
||||
|
||||
/**
|
||||
* A utility class for safely accessing web storage (localStorage or sessionStorage) with error handling.
|
||||
*/
|
||||
export class StorageAccessor {
|
||||
constructor(
|
||||
/**
|
||||
* The key under which the value is stored in the storage backend.
|
||||
*/
|
||||
public readonly key: string,
|
||||
/**
|
||||
* The storage backend to use, e.g. `window.localStorage` or `window.sessionStorage`.
|
||||
*/
|
||||
protected readonly storage: Storage,
|
||||
protected logger = ConsoleLogger.prefix("storage-accessor"),
|
||||
) {
|
||||
if (typeof key !== "string") {
|
||||
throw new TypeError("Storage key must be a string");
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new TypeError("Storage key must be a non-empty string");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link StorageAccessor} for local storage.
|
||||
*
|
||||
* @param key The key under which the value is stored in localStorage.
|
||||
*/
|
||||
public static local = (key: string) => new StorageAccessor(key, self.localStorage);
|
||||
/**
|
||||
* Create a {@link StorageAccessor} for session storage.
|
||||
*
|
||||
* @param key The key under which the value is stored in sessionStorage.
|
||||
*/
|
||||
public static session = (key: string) => new StorageAccessor(key, self.sessionStorage);
|
||||
|
||||
/**
|
||||
* Read the value from storage.
|
||||
*
|
||||
* @param fallback An optional value to return if the key does not exist or an error occurs. Defaults to `null`.
|
||||
*
|
||||
* @returns The stored value, or `null` if the key does not exist or an error occurs.
|
||||
*/
|
||||
public read<T extends string>(fallback?: T): T | null {
|
||||
try {
|
||||
const value = this.storage.getItem(this.key);
|
||||
return value !== null ? (value as T) : (fallback ?? null);
|
||||
} catch (_error: unknown) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value to storage.
|
||||
*
|
||||
* @param value The value to store.
|
||||
*
|
||||
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
|
||||
*/
|
||||
public write(value: string | null): boolean {
|
||||
if (!value) {
|
||||
if (this.read()) {
|
||||
return this.delete();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
this.storage.setItem(this.key, value);
|
||||
return true;
|
||||
} catch (_error: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from storage and parse it as JSON.
|
||||
*
|
||||
* @param fallback An optional value to return if the key does not exist, the value is not valid JSON, or an error occurs. Defaults to `null`.
|
||||
*
|
||||
* @returns The parsed value, or `null` if the key does not exist, the value is not valid JSON, or an error occurs.
|
||||
*/
|
||||
public readJSON<T>(fallback?: T): T | null {
|
||||
const value = this.read<string>();
|
||||
|
||||
if (value === null) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (_error: unknown) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value to storage after stringifying it as JSON.
|
||||
*
|
||||
* @param value The value to store.
|
||||
*
|
||||
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
|
||||
*/
|
||||
public writeJSON(value: unknown): boolean {
|
||||
try {
|
||||
const stringified = JSON.stringify(value);
|
||||
return this.write(stringified);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to write JSON value to storage", error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the value from storage.
|
||||
*
|
||||
* @returns `true` if the value was successfully deleted, or `false` if an error occurred.
|
||||
*/
|
||||
public delete(): boolean {
|
||||
this.logger.debug("Deleting value from storage");
|
||||
|
||||
try {
|
||||
this.storage.removeItem(this.key);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to delete value from storage", error);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement))
|
||||
<a
|
||||
href="${globalAK().api.base}flows/-/default/invalidation/"
|
||||
class="pf-c-button pf-m-plain"
|
||||
aria-label=${msg("Sign out")}
|
||||
>
|
||||
<pf-tooltip position="top" content=${msg("Sign out")}>
|
||||
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
|
||||
|
||||
@@ -414,6 +414,13 @@ export class Form<T = Record<string, unknown>, D = T>
|
||||
|
||||
const { submittingVerb, verboseName } = this.constructor as typeof Form;
|
||||
|
||||
if (!verboseName) {
|
||||
return msg(str`${submittingVerb}...`, {
|
||||
id: "form.submitting.no-entity",
|
||||
desc: "The message shown while a form is being submitted, when no entity name is provided.",
|
||||
});
|
||||
}
|
||||
|
||||
return msg(str`${submittingVerb} ${verboseName}...`, {
|
||||
id: "form.submitting",
|
||||
desc: "The message shown while a form is being submitted.",
|
||||
@@ -615,6 +622,7 @@ export class Form<T = Record<string, unknown>, D = T>
|
||||
protected doSubmit = (event: SubmitEvent): void => {
|
||||
if (this.submitting) {
|
||||
this.logger.info("Skipping submit. Already submitting!");
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
|
||||
@@ -4,6 +4,44 @@
|
||||
|
||||
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
||||
|
||||
export interface FocusErrorOptions extends ErrorOptions {
|
||||
target: Element | null;
|
||||
}
|
||||
|
||||
export class FocusAssertionError extends Error {
|
||||
public override name = "FocusAssertionError";
|
||||
public readonly target: Element | null;
|
||||
|
||||
constructor(message: string, { target, ...options }: FocusErrorOptions) {
|
||||
super(message, options);
|
||||
this.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertFocusable(target: Element | null | undefined): asserts target is HTMLElement {
|
||||
if (!target) {
|
||||
throw new FocusAssertionError("Skipping focus, no target", { target: null });
|
||||
}
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new FocusAssertionError("Skipping focus, target is not an HTMLElement", { target });
|
||||
}
|
||||
|
||||
if (document.activeElement === target) {
|
||||
throw new FocusAssertionError("Target is already focused", { target });
|
||||
}
|
||||
|
||||
// Despite our type definitions, this method isn't available in all browsers,
|
||||
// so we fallback to assuming the element is visible.
|
||||
const visible = target.checkVisibility?.() ?? true;
|
||||
|
||||
if (!visible) {
|
||||
throw new FocusAssertionError("Skipping focus, target is not visible", { target });
|
||||
}
|
||||
|
||||
if (typeof target.focus !== "function") {
|
||||
throw new FocusAssertionError("Skipping focus, target has no focus method", { target });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Recursively check if the target element or any of its children are active (i.e. "focused").
|
||||
*
|
||||
@@ -36,35 +74,17 @@ export function isActiveElement(
|
||||
* @category DOM
|
||||
*/
|
||||
export function isFocusable(target: Element | null | undefined): target is HTMLElement {
|
||||
if (!target) {
|
||||
console.debug("FocusTarget: Skipping focus, no target", target);
|
||||
try {
|
||||
assertFocusable(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof FocusAssertionError) {
|
||||
console.debug(error.message, error.target);
|
||||
} else {
|
||||
console.error("Unexpected error during focus assertion", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
console.debug("FocusTarget: Skipping focus, target is not an HTMLElement", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.activeElement === target) {
|
||||
console.debug("FocusTarget: Target is already focused", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Despite our type definitions, this method isn't available in all browsers,
|
||||
// so we fallback to assuming the element is visible.
|
||||
const visible = target.checkVisibility?.() ?? true;
|
||||
|
||||
if (!visible) {
|
||||
console.debug("FocusTarget: Skipping focus, target is not visible", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof target.focus !== "function") {
|
||||
console.debug("FocusTarget: Skipping focus, target has no focus method", target);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ifPresent } from "#elements/utils/attributes";
|
||||
import { isDefaultAvatar } from "#elements/utils/images";
|
||||
|
||||
import Styles from "#flow/FormStatic.css";
|
||||
import { RememberMeStorage } from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import { StageChallengeLike } from "#flow/types";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
@@ -69,7 +70,9 @@ export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {
|
||||
${flowInfo?.cancelUrl
|
||||
? html`
|
||||
<div slot="link">
|
||||
<a href=${flowInfo.cancelUrl}>${msg("Not you?")}</a>
|
||||
<a href=${flowInfo.cancelUrl} @click=${RememberMeStorage.reset}
|
||||
>${msg("Not you?")}</a
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -121,9 +121,10 @@ export class InputPassword extends AKElement {
|
||||
|
||||
//#region Refs
|
||||
|
||||
inputRef: Ref<HTMLInputElement> = createRef();
|
||||
@property({ attribute: false, useDefault: true })
|
||||
public inputRef: Ref<HTMLInputElement> = createRef();
|
||||
|
||||
toggleVisibilityRef: Ref<HTMLButtonElement> = createRef();
|
||||
public toggleVisibilityRef = createRef<HTMLButtonElement>();
|
||||
|
||||
//#endregion
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export abstract class BaseStage<Tin extends StageChallengeLike, Tout = unknown>
|
||||
@intersectionObserver()
|
||||
public visible = false;
|
||||
|
||||
protected autofocusTarget = new FocusTarget();
|
||||
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
|
||||
focus = this.autofocusTarget.focus;
|
||||
|
||||
#visibilityListener = () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AKLabel } from "#components/ak-label";
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
import AutoRedirect from "#flow/stages/identification/controllers/AutoRedirectController";
|
||||
import CaptchaDisplayController from "#flow/stages/identification/controllers/CaptchaDisplayController";
|
||||
import RememberMe from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import RememberMeController from "#flow/stages/identification/controllers/RememberMeController";
|
||||
import WebauthnController from "#flow/stages/identification/controllers/WebauthnController";
|
||||
import Styles from "#flow/stages/identification/styles.css";
|
||||
|
||||
@@ -30,6 +30,7 @@ import { match } from "ts-pattern";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, nothing, PropertyValues, ReactiveControllerHost } from "lit";
|
||||
import { createRef, ref } from "lit-html/directives/ref.js";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
@@ -45,8 +46,6 @@ type IdentificationFooter = Partial<Pick<IdentificationChallenge, "enrollUrl" |
|
||||
|
||||
export type IdentificationHost = IdentificationStage & ReactiveControllerHost;
|
||||
|
||||
type EmptyString = string | null | undefined;
|
||||
|
||||
export const PasswordManagerPrefill: {
|
||||
password?: string;
|
||||
totp?: string;
|
||||
@@ -82,21 +81,26 @@ export class IdentificationStage extends BaseStage<
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
...RememberMe.styles,
|
||||
...RememberMeController.styles,
|
||||
Styles,
|
||||
];
|
||||
|
||||
/**
|
||||
* The ID of the input field.
|
||||
* The ID of the identifier input field, used for accessibility and focus management.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String, attribute: "input-id" })
|
||||
public inputID = "ak-identifier-input";
|
||||
|
||||
protected passwordFieldRef = createRef<HTMLInputElement>();
|
||||
|
||||
#form?: HTMLFormElement;
|
||||
|
||||
private rememberMe = new RememberMe(this);
|
||||
public defaultUserIdentification: string | null = null;
|
||||
|
||||
protected rememberMeController: RememberMeController | null = null;
|
||||
|
||||
#autoRedirect = new AutoRedirect(this);
|
||||
#captcha = new CaptchaDisplayController(this);
|
||||
#webauthn = new WebauthnController(this);
|
||||
@@ -109,15 +113,23 @@ export class IdentificationStage extends BaseStage<
|
||||
super();
|
||||
// We _define and instantiate_ these fields above, then _read_ them here, and that satisfies
|
||||
// the lint pass that there are no unused private fields.
|
||||
this.addController(this.rememberMe);
|
||||
this.addController(this.#autoRedirect);
|
||||
this.addController(this.#captcha);
|
||||
this.addController(this.#webauthn);
|
||||
}
|
||||
|
||||
#prepareRememberMeFrame = -1;
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("challenge") && this.challenge) {
|
||||
cancelAnimationFrame(this.#prepareRememberMeFrame);
|
||||
|
||||
this.#prepareRememberMeFrame = requestAnimationFrame(() => {
|
||||
this.prepareRememberMeController();
|
||||
});
|
||||
|
||||
this.#createHelperForm();
|
||||
}
|
||||
}
|
||||
@@ -127,10 +139,46 @@ export class IdentificationStage extends BaseStage<
|
||||
this.addEventListener("focus", this.autofocusTarget.toEventListener());
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
cancelAnimationFrame(this.#prepareRememberMeFrame);
|
||||
}
|
||||
|
||||
public override firstUpdated(): void {
|
||||
this.focus();
|
||||
}
|
||||
|
||||
protected prepareRememberMeController(): void {
|
||||
if (!this.challenge) return;
|
||||
|
||||
const { enableRememberMe, pendingUserIdentifier = null } = this.challenge;
|
||||
|
||||
if (!enableRememberMe) {
|
||||
this.defaultUserIdentification = pendingUserIdentifier;
|
||||
|
||||
if (this.rememberMeController) {
|
||||
this.removeController(this.rememberMeController);
|
||||
this.rememberMeController = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.rememberMeController) {
|
||||
this.rememberMeController = new RememberMeController(this, {
|
||||
identificationFieldID: this.inputID,
|
||||
identificationFieldRef: this.autofocusTarget.reference,
|
||||
passwordFieldRef: this.passwordFieldRef,
|
||||
pendingUserIdentifier,
|
||||
});
|
||||
|
||||
this.addController(this.rememberMeController);
|
||||
}
|
||||
|
||||
this.defaultUserIdentification = this.rememberMeController.defaultUserIdentification;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Helper Form
|
||||
@@ -247,11 +295,11 @@ export class IdentificationStage extends BaseStage<
|
||||
id: string,
|
||||
type: string,
|
||||
label: string,
|
||||
username: EmptyString,
|
||||
initialUserIdentification: string | null,
|
||||
autocomplete: string,
|
||||
) {
|
||||
return html`<input
|
||||
${this.autofocusTarget.toRef()}
|
||||
${ref(this.autofocusTarget.reference)}
|
||||
id=${id}
|
||||
type=${type}
|
||||
name="uidField"
|
||||
@@ -260,56 +308,57 @@ export class IdentificationStage extends BaseStage<
|
||||
autocomplete=${autocomplete}
|
||||
spellcheck="false"
|
||||
class="pf-c-form-control"
|
||||
value=${username ?? ""}
|
||||
value=${initialUserIdentification ?? ""}
|
||||
required
|
||||
/>`;
|
||||
}
|
||||
|
||||
protected renderPasswordFields(challenge: IdentificationChallenge) {
|
||||
const { allowShowPassword } = challenge;
|
||||
return html`
|
||||
<ak-flow-input-password
|
||||
label=${msg("Password")}
|
||||
input-id="ak-stage-identification-password"
|
||||
class="pf-c-form__group"
|
||||
.errors=${challenge.responseErrors?.password}
|
||||
?allow-show-password=${allowShowPassword}
|
||||
prefill=${PasswordManagerPrefill.password ?? ""}
|
||||
></ak-flow-input-password>
|
||||
`;
|
||||
return html`<ak-flow-input-password
|
||||
.inputRef=${this.passwordFieldRef}
|
||||
label=${msg("Password")}
|
||||
input-id="ak-stage-identification-password"
|
||||
class="pf-c-form__group"
|
||||
.errors=${challenge.responseErrors?.password}
|
||||
?allow-show-password=${allowShowPassword}
|
||||
prefill=${PasswordManagerPrefill.password ?? ""}
|
||||
></ak-flow-input-password> `;
|
||||
}
|
||||
|
||||
protected renderInput(challenge: IdentificationChallenge) {
|
||||
const {
|
||||
flowDesignation,
|
||||
passwordFields,
|
||||
passwordlessUrl,
|
||||
pendingUserIdentifier,
|
||||
primaryAction,
|
||||
userFields,
|
||||
} = challenge;
|
||||
const { flowDesignation, passwordFields, passwordlessUrl, primaryAction, userFields } =
|
||||
challenge;
|
||||
|
||||
const fields = (userFields || []).sort();
|
||||
if (fields.length === 0) {
|
||||
return html`<p>${msg("Select one of the options below to continue.")}</p>`;
|
||||
}
|
||||
|
||||
const { inputID, rememberMe } = this;
|
||||
const {
|
||||
inputID,
|
||||
defaultUserIdentification: initialUserIdentification,
|
||||
rememberMeController,
|
||||
} = this;
|
||||
|
||||
const offerRecovery = flowDesignation === FlowDesignationEnum.Recovery;
|
||||
const type = fields.length === 1 && fields[0] === UserFieldsEnum.Email ? "email" : "text";
|
||||
const label = OR_LIST_FORMATTERS.format(fields.map((f) => UI_FIELDS[f]));
|
||||
const username = rememberMe.username ?? pendingUserIdentifier;
|
||||
|
||||
// When webauthn is enabled, add "webauthn" to autocomplete to enable passkey autofill
|
||||
const autocomplete: AutoFill = this.#webauthn.live ? "username webauthn" : "username";
|
||||
|
||||
console.debug(
|
||||
"Rendering identification stage with fields:",
|
||||
fields,
|
||||
initialUserIdentification,
|
||||
);
|
||||
// prettier-ignore
|
||||
return html`${offerRecovery ? this.renderRecoveryMessage() : nothing}
|
||||
<div class="pf-c-form__group">
|
||||
${AKLabel({ required: true, htmlFor: inputID }, label)}
|
||||
${this.renderUidField(inputID, type, label, username, autocomplete)}
|
||||
${rememberMe.render()}
|
||||
${this.renderUidField(inputID, type, label, initialUserIdentification, autocomplete)}
|
||||
${rememberMeController?.renderToggleInput() ?? null}
|
||||
${AKFormErrors({ errors: challenge.responseErrors?.uid_field })}
|
||||
</div>
|
||||
${passwordFields ? this.renderPasswordFields(challenge) : nothing}
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { StorageAccessor } from "#common/storage";
|
||||
import { getCookie } from "#common/utils";
|
||||
|
||||
import { ReactiveElementHost } from "#elements/types";
|
||||
|
||||
import type { IdentificationStage } from "#flow/stages/identification/IdentificationStage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, nothing, ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { ConsoleLogger } from "#logger/browser";
|
||||
|
||||
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, ReactiveController } from "lit";
|
||||
import { createRef, Ref } from "lit-html/directives/ref.js";
|
||||
|
||||
export class RememberMeStorage {
|
||||
static readonly user = StorageAccessor.local("authentik-remember-me-user");
|
||||
static readonly session = StorageAccessor.local("authentik-remember-me-session");
|
||||
static reset = () => {
|
||||
this.user.delete();
|
||||
this.session.delete();
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionID() {
|
||||
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
|
||||
}
|
||||
|
||||
export interface RememberMeControllerInit {
|
||||
pendingUserIdentifier: string | null;
|
||||
identificationFieldRef: Ref<HTMLInputElement>;
|
||||
passwordFieldRef: Ref<HTMLInputElement> | null;
|
||||
identificationFieldID: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the user's `username` "on this device."
|
||||
@@ -24,7 +48,7 @@ type RememberMeHost = ReactiveControllerHost & IdentificationStage;
|
||||
* came back to this view after reaching the identity proof phase, indicating they pressed the "not
|
||||
* you?" link, at which point it begins again to record the username as it is typed in.
|
||||
*/
|
||||
export class RememberMe implements ReactiveController {
|
||||
export class RememberMeController implements ReactiveController {
|
||||
static readonly styles = [
|
||||
css`
|
||||
.remember-me-switch {
|
||||
@@ -35,121 +59,178 @@ export class RememberMe implements ReactiveController {
|
||||
`,
|
||||
];
|
||||
|
||||
public username?: string;
|
||||
//#region Lifecycle
|
||||
|
||||
#trackRememberMe = () => {
|
||||
if (!this.#usernameField || this.#usernameField.value === undefined) {
|
||||
return;
|
||||
}
|
||||
this.username = this.#usernameField.value;
|
||||
localStorage?.setItem("authentik-remember-me-user", this.username);
|
||||
};
|
||||
public readonly identificationFieldRef: Ref<HTMLInputElement>;
|
||||
public readonly passwordFieldRef: Ref<HTMLInputElement> | null;
|
||||
public readonly defaultChecked: boolean;
|
||||
public readonly defaultUserIdentification: string | null;
|
||||
public readonly identificationFieldID: string;
|
||||
|
||||
// When active, save current details and record every keystroke to the username.
|
||||
// When inactive, clear all fields and remove keystroke recorder.
|
||||
#toggleRememberMe = () => {
|
||||
if (!this.#rememberMeToggle || !this.#rememberMeToggle.checked) {
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
localStorage?.removeItem("authentik-remember-me-session");
|
||||
this.username = undefined;
|
||||
this.#usernameField?.removeEventListener("keyup", this.#trackRememberMe);
|
||||
return;
|
||||
}
|
||||
if (!this.#usernameField) {
|
||||
return;
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-user", this.#usernameField.value);
|
||||
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
|
||||
this.#usernameField.addEventListener("keyup", this.#trackRememberMe);
|
||||
};
|
||||
protected logger = ConsoleLogger.prefix("controller/remember-me");
|
||||
protected autoSubmitAttempts = 0;
|
||||
protected currentSessionID = readSessionID();
|
||||
|
||||
constructor(private host: RememberMeHost) {}
|
||||
constructor(
|
||||
protected host: ReactiveElementHost<IdentificationStage>,
|
||||
{
|
||||
identificationFieldRef,
|
||||
passwordFieldRef,
|
||||
identificationFieldID,
|
||||
}: RememberMeControllerInit,
|
||||
) {
|
||||
this.identificationFieldRef = identificationFieldRef;
|
||||
this.passwordFieldRef = passwordFieldRef || null;
|
||||
this.identificationFieldID = identificationFieldID;
|
||||
|
||||
// Record a stable token that we can use between requests to track if we've
|
||||
// been here before. If we can't, clear out the username.
|
||||
public hostConnected() {
|
||||
try {
|
||||
const sessionId = localStorage.getItem("authentik-remember-me-session");
|
||||
if (!!this.#localSession && sessionId === this.#localSession) {
|
||||
this.username = undefined;
|
||||
localStorage?.removeItem("authentik-remember-me-user");
|
||||
}
|
||||
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (_e: any) {
|
||||
this.username = undefined;
|
||||
}
|
||||
}
|
||||
const persistedSessionID = RememberMeStorage.session.read();
|
||||
|
||||
get #localSession() {
|
||||
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
|
||||
}
|
||||
|
||||
get #usernameField() {
|
||||
return this.host.renderRoot.querySelector(
|
||||
'input[name="uidField"]',
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
get #rememberMeToggle() {
|
||||
return this.host.renderRoot.querySelector(
|
||||
"#authentik-remember-me",
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
get #submitButton() {
|
||||
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
get #isEnabled() {
|
||||
return this.host.challenge?.enableRememberMe && typeof localStorage !== "undefined";
|
||||
}
|
||||
|
||||
get #canAutoSubmit() {
|
||||
return (
|
||||
!!this.host.challenge &&
|
||||
!!this.username &&
|
||||
!!this.#usernameField?.value &&
|
||||
!this.host.challenge.passwordFields &&
|
||||
!this.host.challenge.passwordlessUrl
|
||||
);
|
||||
}
|
||||
|
||||
// Before the page is updated, try to extract the username from localstorage.
|
||||
public hostUpdate() {
|
||||
if (!this.#isEnabled) {
|
||||
return;
|
||||
if (persistedSessionID && persistedSessionID !== this.currentSessionID) {
|
||||
this.logger.debug("Session ID mismatch, clearing remembered username");
|
||||
RememberMeStorage.user.delete();
|
||||
}
|
||||
|
||||
try {
|
||||
this.username = localStorage.getItem("authentik-remember-me-user") || undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (_e: any) {
|
||||
this.username = undefined;
|
||||
}
|
||||
const persistedUserIdentifier = RememberMeStorage.user.read();
|
||||
|
||||
this.defaultUserIdentification =
|
||||
persistedUserIdentifier || this.host.challenge?.pendingUserIdentifier || null;
|
||||
|
||||
this.defaultChecked = !!persistedUserIdentifier;
|
||||
}
|
||||
|
||||
// After the page is updated, if everything is ready to go, do the autosubmit.
|
||||
public hostUpdated() {
|
||||
if (this.#isEnabled && this.#canAutoSubmit) {
|
||||
this.#submitButton?.click();
|
||||
if (this.canAutoSubmit() && this.autoSubmitAttempts === 0) {
|
||||
this.autoSubmitAttempts++;
|
||||
this.host.submitForm?.();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.#isEnabled
|
||||
? html` <label class="pf-c-switch remember-me-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
id="authentik-remember-me"
|
||||
@click=${this.#toggleRememberMe}
|
||||
type="checkbox"
|
||||
?checked=${!!this.username}
|
||||
/>
|
||||
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
|
||||
</label>`
|
||||
: nothing;
|
||||
//#region Event Listeners
|
||||
|
||||
#writeFrameID = -1;
|
||||
|
||||
public inputListener = (event: InputEvent) => {
|
||||
cancelAnimationFrame(this.#writeFrameID);
|
||||
const { value } = event.target as HTMLInputElement;
|
||||
|
||||
this.#writeFrameID = requestAnimationFrame(() => {
|
||||
RememberMeStorage.user.write(value);
|
||||
});
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public API
|
||||
|
||||
/**
|
||||
* Toggle the "remember me" feature on or off.
|
||||
*
|
||||
* When toggled on, the current username is saved to localStorage and will be automatically
|
||||
* submitted on future visits. Additionally, every keystroke in the username field will update
|
||||
* the stored username.
|
||||
*
|
||||
* When toggled off, any stored username is cleared from localStorage, and the keystroke listener
|
||||
* is removed to stop updating the stored username.
|
||||
*/
|
||||
public toggleChangeListener = (event: Event) => {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
const { usernameField, passwordField } = this;
|
||||
|
||||
if (!checkbox.checked) {
|
||||
this.logger.debug("Disabling remember me");
|
||||
|
||||
RememberMeStorage.reset();
|
||||
|
||||
if (usernameField) {
|
||||
usernameField.removeEventListener("input", this.inputListener);
|
||||
usernameField.focus();
|
||||
usernameField.select();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usernameField) {
|
||||
this.logger.warn("Cannot enable remember me: no username field found");
|
||||
return;
|
||||
}
|
||||
|
||||
const focusTarget = passwordField && usernameField?.value ? passwordField : usernameField;
|
||||
|
||||
if (focusTarget) {
|
||||
focusTarget.focus();
|
||||
focusTarget.select();
|
||||
}
|
||||
|
||||
this.logger.debug("Enabling remember me for user");
|
||||
|
||||
RememberMeStorage.user.write(usernameField.value);
|
||||
RememberMeStorage.session.write(this.currentSessionID);
|
||||
|
||||
usernameField.addEventListener("input", this.inputListener, {
|
||||
passive: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the "remember me" feature can be automatically submitted, which requires:
|
||||
*
|
||||
* - An active challenge.
|
||||
* - A stored username from a previous session.
|
||||
* - The identifier input field to be present in the DOM.
|
||||
* - No password fields or passwordless URL, indicating we can skip directly to the next step.
|
||||
*/
|
||||
public canAutoSubmit(): boolean {
|
||||
const { challenge } = this.host;
|
||||
|
||||
if (!challenge) return false;
|
||||
if (!challenge.enableRememberMe) return false;
|
||||
|
||||
if (challenge.passwordFields) return false;
|
||||
if (challenge.passwordlessUrl) return false;
|
||||
|
||||
if (!this.defaultChecked) return false;
|
||||
return !!this.usernameField?.value;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
protected readonly checkboxRef = createRef<HTMLInputElement>();
|
||||
|
||||
protected get usernameField() {
|
||||
return this.identificationFieldRef.value || null;
|
||||
}
|
||||
|
||||
protected get passwordField() {
|
||||
return this.passwordFieldRef?.value || null;
|
||||
}
|
||||
|
||||
protected get checkboxToggle() {
|
||||
return this.checkboxRef.value || null;
|
||||
}
|
||||
public renderToggleInput = () => {
|
||||
return html`<label
|
||||
class="pf-c-switch remember-me-switch"
|
||||
for="authentik-remember-me"
|
||||
aria-description=${msg(
|
||||
"When enabled, your username will be remembered on this device for future logins.",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
id="authentik-remember-me"
|
||||
@change=${this.toggleChangeListener}
|
||||
?checked=${this.defaultChecked}
|
||||
/>
|
||||
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
|
||||
</label>`;
|
||||
};
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export default RememberMe;
|
||||
export default RememberMeController;
|
||||
|
||||
@@ -179,7 +179,7 @@ test.describe("Groups", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Edit group from view page", async ({ navigator, form, pointer, page }, testInfo) => {
|
||||
test("Edit group from view page", async ({ form, pointer, page }, testInfo) => {
|
||||
const groupName = groupNames.get(testInfo.testId)!;
|
||||
|
||||
const { fill, search } = form;
|
||||
|
||||
@@ -17,11 +17,7 @@ test.describe("Provider Wizard", () => {
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "New Provider Wizard" });
|
||||
|
||||
await test.step("Authenticate", async () => {
|
||||
await session.login({
|
||||
to: "/if/admin/#/core/providers",
|
||||
});
|
||||
});
|
||||
await test.step("Authenticate", async () => session.login());
|
||||
|
||||
await test.step("Navigate to provider wizard", async () => {
|
||||
await expect(dialog, "Dialog is initially closed").toBeHidden();
|
||||
|
||||
119
web/test/browser/session-lifecycle.test.ts
Normal file
119
web/test/browser/session-lifecycle.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import { FormFixture } from "#e2e/fixtures/FormFixture";
|
||||
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
|
||||
import { GOOD_USERNAME, SessionFixture } from "#e2e/fixtures/SessionFixture";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const REMEMBER_ME_USER_KEY = "authentik-remember-me-user";
|
||||
const REMEMBER_ME_SESSION_KEY = "authentik-remember-me-session";
|
||||
|
||||
const IDENTIFICATION_STAGE_NAME = "default-authentication-identification";
|
||||
|
||||
const readStoredUserIdentifier = (page: Page) =>
|
||||
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_USER_KEY);
|
||||
|
||||
test.describe("Session Lifecycle", () => {
|
||||
test.beforeAll(
|
||||
'Ensure "Enable Remember me on this device" is on for the default identification stage',
|
||||
async ({ browser }, { title: testName }) => {
|
||||
if (Date.now()) return;
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const navigator = new NavigatorFixture(page, testName);
|
||||
const form = new FormFixture(page, testName);
|
||||
const session = new SessionFixture({ page, testName, navigator });
|
||||
|
||||
await test.step("Authenticate", async () =>
|
||||
session.login({
|
||||
to: "/if/admin/#/flow/stages",
|
||||
page,
|
||||
}));
|
||||
|
||||
const $stage = await test.step("Find stage via search", () =>
|
||||
form.search(IDENTIFICATION_STAGE_NAME, page));
|
||||
|
||||
await $stage.getByRole("button", { name: "Edit Stage" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Edit Identification Stage" });
|
||||
await expect(dialog, "Edit modal opens after clicking edit").toBeVisible();
|
||||
|
||||
await form.setInputCheck(`Enable "Remember me on this device"`, true, dialog);
|
||||
await dialog.getByRole("button", { name: "Save Changes" }).click();
|
||||
await expect(dialog, "Edit modal closes after save").toBeHidden();
|
||||
|
||||
await context.close();
|
||||
},
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ session, page }) => {
|
||||
await session.toLoginPage();
|
||||
|
||||
await page.evaluate(
|
||||
([userKey, sessionKey]) => {
|
||||
localStorage.removeItem(userKey);
|
||||
localStorage.removeItem(sessionKey);
|
||||
},
|
||||
[REMEMBER_ME_USER_KEY, REMEMBER_ME_SESSION_KEY],
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await session.$identificationStage.waitFor({ state: "visible" });
|
||||
});
|
||||
|
||||
test("Remember me persists username", async ({ navigator, session, page }) => {
|
||||
await test.step("Verify identification stage", async () => {
|
||||
await expect(
|
||||
session.$rememberMeCheckbox,
|
||||
"Remember me checkbox is visible",
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
session.$rememberMeCheckbox,
|
||||
"Remember me checkbox is not checked by default",
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
await test.step("Identify with remember-me enabled", async () => {
|
||||
await session.login(
|
||||
{
|
||||
rememberMe: true,
|
||||
to: "if/user/#/library",
|
||||
},
|
||||
page,
|
||||
);
|
||||
|
||||
const storedUserIdentifier = await readStoredUserIdentifier(page);
|
||||
|
||||
expect(
|
||||
storedUserIdentifier,
|
||||
"username persists to localStorage when remember-me is checked",
|
||||
).toBe(GOOD_USERNAME);
|
||||
});
|
||||
|
||||
await test.step("Sign out and verify username is remembered", async () => {
|
||||
const signOutLink = page.getByRole("link", { name: "Sign out" });
|
||||
|
||||
await expect(signOutLink, "Sign out link is visible").toBeVisible();
|
||||
|
||||
await signOutLink.click();
|
||||
|
||||
await navigator.waitForPathname("/if/flow/default-authentication-flow/?next=%2F");
|
||||
|
||||
const notYouLink = page.getByRole("link", { name: "Not you?" });
|
||||
|
||||
await expect(notYouLink, "Not you? link is visible after sign out").toBeVisible();
|
||||
|
||||
await notYouLink.click();
|
||||
|
||||
await expect(
|
||||
session.$identificationStage,
|
||||
"Identification stage is visible after clicking not you link",
|
||||
).toBeVisible();
|
||||
|
||||
const storedUserIdentifier = await readStoredUserIdentifier(page);
|
||||
|
||||
expect(storedUserIdentifier, "Removed after clicking not you link").toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"experimentalDecorators": true,
|
||||
|
||||
// `useDefineForClassFields` is required for Lit decorator compatibility.
|
||||
// See the Lit documentation
|
||||
// [Typescript class fields for reactive properties](https://lit.dev/docs/components/properties/#:~:text=Set%20the%20useDefineForClassFields%20compiler%20option%20to%20false)
|
||||
// for details.
|
||||
// See https://lit.dev/docs/components/properties/
|
||||
"useDefineForClassFields": false,
|
||||
"target": "esnext",
|
||||
"module": "preserve",
|
||||
|
||||
Reference in New Issue
Block a user