Users & Groups fixes/changes

Signed-off-by: apeters <apeters@korves.net>
This commit is contained in:
apeters 2025-05-23 08:47:10 +00:00
parent ee1f3e59b5
commit 36e606693b
16 changed files with 397 additions and 240 deletions

View File

@ -27,23 +27,26 @@ async def cli_processor(streams: tuple[asyncio.StreamReader, asyncio.StreamWrite
await writer.drain()
elif cmd == b"\x98":
awaiting = dict()
idx = 1
for k, v in IN_MEMORY_DB.items():
if (
isinstance(v, dict)
and v.get("token_type") == "cli_confirmation"
):
awaiting[idx] = (k, v["intention"])
idx += 1
tokens = (
IN_MEMORY_DB["TOKENS"]["LOGIN"] | IN_MEMORY_DB["TOKENS"]["REGISTER"]
)
for idx, (k, v) in enumerate(tokens.items(), start=1):
awaiting[idx] = (k, v["intention"])
writer.write(f"{json.dumps(awaiting)}\n".encode("ascii"))
await writer.drain()
elif cmd == b"\x99":
data = await reader.readexactly(14)
confirmed = data.strip().decode("ascii")
code = "%06d" % random.randint(0, 999999)
IN_MEMORY_DB.get(confirmed, {}).update(
{"status": "confirmed", "code": code}
)
if confirmed in IN_MEMORY_DB["TOKENS"]["LOGIN"]:
IN_MEMORY_DB["TOKENS"]["LOGIN"].get(confirmed, {}).update(
{"status": "confirmed", "code": code}
)
elif confirmed in IN_MEMORY_DB["TOKENS"]["REGISTER"]:
IN_MEMORY_DB["TOKENS"]["REGISTER"].get(confirmed, {}).update(
{"status": "confirmed", "code": code}
)
writer.write(f"{code}\n".encode("ascii"))
await writer.drain()
except Exception as e:

View File

@ -23,6 +23,7 @@ app.register_blueprint(objects.blueprint)
app.register_blueprint(profile.blueprint)
app.register_blueprint(system.blueprint)
app.register_blueprint(users.blueprint)
app.register_blueprint(groups.blueprint)
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = defaults.SEND_FILE_MAX_AGE_DEFAULT
app.config["SECRET_KEY"] = defaults.SECRET_KEY
@ -35,6 +36,10 @@ IN_MEMORY_DB["FORM_OPTIONS_CACHE"] = dict()
IN_MEMORY_DB["OBJECTS_CACHE"] = dict()
IN_MEMORY_DB["APP_LOGS_FULL_PULL"] = dict()
IN_MEMORY_DB["PROMOTE_USERS"] = set()
IN_MEMORY_DB["TOKENS"] = {
"REGISTER": dict(),
"LOGIN": dict(),
}
modifying_request_limiter = asyncio.Semaphore(app.config["MOD_REQ_LIMIT"])

View File

@ -4,3 +4,4 @@ from components.web.blueprints import profile
from components.web.blueprints import root
from components.web.blueprints import system
from components.web.blueprints import users
from components.web.blueprints import groups

View File

@ -51,11 +51,13 @@ async def login_request_confirm(request_token: str):
except:
return "", 200, {"HX-Redirect": "/"}
token_status = IN_MEMORY_DB.get(request_token, {}).get("status")
token_status = IN_MEMORY_DB["TOKENS"]["LOGIN"].get(request_token, {}).get("status")
if token_status == "awaiting":
session["request_token"] = request_token
requested_login = IN_MEMORY_DB[request_token]["requested_login"]
requested_login = IN_MEMORY_DB["TOKENS"]["LOGIN"][request_token][
"requested_login"
]
return await render_template(
"auth/login/request/confirm.html",
@ -87,10 +89,10 @@ async def login_request_confirm_modal(request_token: str):
if request.method == "POST":
if (
request_token in IN_MEMORY_DB
and IN_MEMORY_DB[request_token]["status"] == "awaiting"
request_token in IN_MEMORY_DB["TOKENS"]["LOGIN"]
and IN_MEMORY_DB["TOKENS"]["LOGIN"][request_token]["status"] == "awaiting"
):
IN_MEMORY_DB[request_token].update(
IN_MEMORY_DB["TOKENS"]["LOGIN"][request_token].update(
{
"status": "confirmed",
"credential_id": "",
@ -98,7 +100,7 @@ async def login_request_confirm_modal(request_token: str):
)
current_app.add_background_task(
expire_key,
IN_MEMORY_DB,
IN_MEMORY_DB["TOKENS"]["LOGIN"],
request_token,
10,
)
@ -136,16 +138,16 @@ async def login_request_start():
request_token = token_urlsafe()
IN_MEMORY_DB[request_token] = {
IN_MEMORY_DB["TOKENS"]["LOGIN"][request_token] = {
"intention": f"Authenticate user: {request_data.login}",
"created": utc_now_as_str(),
"status": "awaiting",
"token_type": "web_confirmation",
"requested_login": request_data.login,
}
current_app.add_background_task(
expire_key,
IN_MEMORY_DB,
IN_MEMORY_DB["TOKENS"]["LOGIN"],
request_token,
defaults.AUTH_REQUEST_TIMEOUT,
)
@ -177,7 +179,7 @@ async def login_request_check(request_token: str):
return "", 200, {"HX-Redirect": "/"}
token_status, requested_login, credential_id = map(
IN_MEMORY_DB.get(request_token, {}).get,
IN_MEMORY_DB["TOKENS"]["LOGIN"].get(request_token, {}).get,
["status", "requested_login", "credential_id"],
)
@ -216,15 +218,15 @@ async def login_token():
try:
request_data = AuthToken.parse_obj(request.form_parsed)
token = request_data.token
IN_MEMORY_DB[token] = {
IN_MEMORY_DB["TOKENS"]["LOGIN"][token] = {
"intention": f"Authenticate user: {request_data.login}",
"created": utc_now_as_str(),
"status": "awaiting",
"token_type": "cli_confirmation",
"login": request_data.login,
}
current_app.add_background_task(
expire_key,
IN_MEMORY_DB,
IN_MEMORY_DB["TOKENS"]["LOGIN"],
token,
120,
)
@ -244,10 +246,10 @@ async def login_token_verify():
request_data = TokenConfirmation.parse_obj(request.form_parsed)
token_status, token_login, token_confirmation_code = map(
IN_MEMORY_DB.get(request_data.token, {}).get,
IN_MEMORY_DB["TOKENS"]["LOGIN"].get(request_data.token, {}).get,
["status", "login", "code"],
)
IN_MEMORY_DB.pop(request_data.token, None)
IN_MEMORY_DB["TOKENS"]["LOGIN"].pop(request_data.token, None)
if (
token_status != "confirmed"
@ -328,17 +330,17 @@ async def register_token():
try:
request_data = AuthToken.parse_obj(request.form_parsed)
token = request_data.token
IN_MEMORY_DB[token] = {
IN_MEMORY_DB["TOKENS"]["REGISTER"][token] = {
"intention": f"Register user: {request_data.login}",
"created": utc_now_as_str(),
"status": "awaiting",
"token_type": "cli_confirmation",
"login": request_data.login,
}
current_app.add_background_task(
expire_key,
IN_MEMORY_DB,
IN_MEMORY_DB["TOKENS"]["REGISTER"],
token,
120,
defaults.REGISTER_REQUEST_TIMEOUT,
)
await ws_htmx(
"_system",
@ -365,10 +367,10 @@ async def register_webauthn_options():
return validation_error(e.errors())
token_status, token_login, token_confirmation_code = map(
IN_MEMORY_DB.get(request_data.token, {}).get,
IN_MEMORY_DB["TOKENS"]["REGISTER"].get(request_data.token, {}).get,
["status", "login", "code"],
)
IN_MEMORY_DB.pop(request_data.token, None)
IN_MEMORY_DB["TOKENS"]["REGISTER"].pop(request_data.token, None)
if (
token_status != "confirmed"
@ -614,7 +616,7 @@ async def auth_login_verify():
Not setting session login and id for device that is confirming the proxy authentication
Gracing 10s for the awaiting party to catch up an almost expired key
"""
IN_MEMORY_DB[request_token].update(
IN_MEMORY_DB["TOKENS"]["LOGIN"][request_token].update(
{
"status": "confirmed",
"credential_id": credential.raw_id.hex(),
@ -622,7 +624,7 @@ async def auth_login_verify():
)
current_app.add_background_task(
expire_key,
IN_MEMORY_DB,
IN_MEMORY_DB["TOKENS"]["LOGIN"],
request_token,
10,
)

View File

@ -0,0 +1,65 @@
import components.users
from components.models.users import UserGroups
from components.web.utils import *
blueprint = Blueprint("groups", __name__, url_prefix="/system/groups")
@blueprint.context_processor
def load_context():
from components.models.users import UserProfile
context = dict()
context["schemas"] = {"user_profile": UserProfile.model_json_schema()}
return context
@blueprint.route("/", methods=["PATCH"])
@acl("system")
async def user_group():
try:
request_data = UserGroups.parse_obj(request.form_parsed)
assigned_to = [
u
for u in await components.users.search(name="", join_credentials=False)
if request_data.name in u.groups
]
assign_to = []
for user_id in request_data.members:
assign_to.append(
await components.users.get(user_id=user_id, join_credentials=False)
)
_all = assigned_to + assign_to
async with ClusterLock(["users", "credentials"], current_app):
for user in _all:
user_dict = user.model_dump(mode="json")
if request_data.name in user_dict["groups"]:
user_dict["groups"].remove(request_data.name)
if (
request_data.new_name not in user_dict["groups"]
and user in assign_to
):
user_dict["groups"].append(request_data.new_name)
await components.users.patch(user_id=user.id, data=user_dict)
return "", 204
except ValidationError as e:
return validation_error(e.errors())
except ValueError as e:
name, message = e.args
return validation_error([{"loc": [name], "msg": message}])
@blueprint.route("/")
@acl("system")
@formoptions(["users"])
async def get_groups():
return await render_template("system/groups.html", data={})

View File

@ -1,7 +1,6 @@
import components.users
from components.utils import batch, ensure_list
from components.database import IN_MEMORY_DB
from components.models.users import UserGroups
from components.web.utils import *
@ -17,49 +16,6 @@ def load_context():
return context
@blueprint.route("/groups", methods=["PATCH"])
@acl("system")
async def user_group():
try:
request_data = UserGroups.parse_obj(request.form_parsed)
assigned_to = [
u
for u in await components.users.search(name="", join_credentials=False)
if request_data.name in u.groups
]
assign_to = []
for user_id in request_data.members:
assign_to.append(
await components.users.get(user_id=user_id, join_credentials=False)
)
_all = assigned_to + assign_to
async with ClusterLock(["users", "credentials"], current_app):
for user in _all:
user_dict = user.model_dump(mode="json")
if request_data.name in user_dict["groups"]:
user_dict["groups"].remove(request_data.name)
if (
request_data.new_name not in user_dict["groups"]
and user in assign_to
):
user_dict["groups"].append(request_data.new_name)
await components.users.patch(user_id=user.id, data=user_dict)
return "", 204
except ValidationError as e:
return validation_error(e.errors())
except ValueError as e:
name, message = e.args
return validation_error([{"loc": [name], "msg": message}])
@blueprint.route("/<user_id>")
@acl("system")
async def get_user(user_id: str):
@ -128,7 +84,9 @@ async def get_users():
},
)
else:
return await render_template("system/users.html", data={})
return await render_template(
"system/users.html", data={"tokens": IN_MEMORY_DB["TOKENS"]}
)
@blueprint.route("/delete", methods=["POST"])

View File

@ -1,12 +1,13 @@
@charset "UTF-8";
/*!
* Pico CSS v2.0.6 (https://picocss.com)
* Copyright 2019-2024 - Licensed under MIT
* Pico CSS v2.1.1 (https://picocss.com)
* Copyright 2019-2025 - Licensed under MIT
*/
/**
* Styles
*/
:root {
:root,
:host {
--pico-font-family-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--pico-font-family-sans-serif: system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, Helvetica, Arial, "Helvetica Neue", sans-serif, var(--pico-font-family-emoji);
--pico-font-family-monospace: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace, var(--pico-font-family-emoji);
@ -271,12 +272,14 @@ details summary[role=button]:not(.outline)::after {
* Color schemes
*/
[data-theme=light],
:root:not([data-theme=dark]) {
:root:not([data-theme=dark]),
:host(:not([data-theme=dark])) {
color-scheme: light;
--pico-background-color: #fff;
--pico-color: #373c44;
--pico-text-selection-color: rgba(129, 145, 181, 0.25);
--pico-muted-color: #646b79;
--pico-muted-border-color: #e7eaf0;
--pico-muted-border-color: rgb(231, 234, 239.5);
--pico-primary: #5d6b89;
--pico-primary-background: #525f7a;
--pico-primary-border: var(--pico-primary-background);
@ -314,21 +317,21 @@ details summary[role=button]:not(.outline)::after {
--pico-h4-color: #4d535e;
--pico-h5-color: #5c6370;
--pico-h6-color: #646b79;
--pico-mark-background-color: #fde7c0;
--pico-mark-background-color: rgb(252.5, 230.5, 191.5);
--pico-mark-color: #0f1114;
--pico-ins-color: #1d6a54;
--pico-del-color: #883935;
--pico-ins-color: rgb(28.5, 105.5, 84);
--pico-del-color: rgb(136, 56.5, 53);
--pico-blockquote-border-color: var(--pico-muted-border-color);
--pico-blockquote-footer-color: var(--pico-muted-color);
--pico-button-box-shadow: 0 0 0 rgba(0, 0, 0, 0);
--pico-button-hover-box-shadow: 0 0 0 rgba(0, 0, 0, 0);
--pico-table-border-color: var(--pico-muted-border-color);
--pico-table-row-stripped-background-color: rgba(111, 120, 135, 0.0375);
--pico-code-background-color: #f3f5f7;
--pico-code-background-color: rgb(243, 244.5, 246.75);
--pico-code-color: #646b79;
--pico-code-kbd-background-color: var(--pico-color);
--pico-code-kbd-color: var(--pico-background-color);
--pico-form-element-background-color: #fbfcfc;
--pico-form-element-background-color: rgb(251, 251.5, 252.25);
--pico-form-element-selected-background-color: #dfe3eb;
--pico-form-element-border-color: #cfd5e2;
--pico-form-element-color: #23262c;
@ -337,11 +340,11 @@ details summary[role=button]:not(.outline)::after {
--pico-form-element-active-border-color: var(--pico-primary-border);
--pico-form-element-focus-color: var(--pico-primary-border);
--pico-form-element-disabled-opacity: 0.5;
--pico-form-element-invalid-border-color: #b86a6b;
--pico-form-element-invalid-active-border-color: #c84f48;
--pico-form-element-invalid-border-color: rgb(183.5, 105.5, 106.5);
--pico-form-element-invalid-active-border-color: rgb(200.25, 79.25, 72.25);
--pico-form-element-invalid-focus-color: var(--pico-form-element-invalid-active-border-color);
--pico-form-element-valid-border-color: #4c9b8a;
--pico-form-element-valid-active-border-color: #279977;
--pico-form-element-valid-border-color: rgb(76, 154.5, 137.5);
--pico-form-element-valid-active-border-color: rgb(39, 152.75, 118.75);
--pico-form-element-valid-focus-color: var(--pico-form-element-valid-active-border-color);
--pico-switch-background-color: #bfc7d9;
--pico-switch-checked-background-color: var(--pico-primary-background);
@ -359,7 +362,7 @@ details summary[role=button]:not(.outline)::after {
--pico-card-background-color: var(--pico-background-color);
--pico-card-border-color: var(--pico-muted-border-color);
--pico-card-box-shadow: var(--pico-box-shadow);
--pico-card-sectioning-background-color: #fbfcfc;
--pico-card-sectioning-background-color: rgb(251, 251.5, 252.25);
--pico-dropdown-background-color: #fff;
--pico-dropdown-border-color: #eff1f4;
--pico-dropdown-box-shadow: var(--pico-box-shadow);
@ -371,9 +374,8 @@ details summary[role=button]:not(.outline)::after {
--pico-progress-color: var(--pico-primary-background);
--pico-tooltip-background-color: var(--pico-contrast-background);
--pico-tooltip-color: var(--pico-contrast-inverse);
--pico-icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 155, 138)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
--pico-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200, 79, 72)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");
color-scheme: light;
--pico-icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
--pico-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");
}
[data-theme=light] input:is([type=submit],
[type=button],
@ -386,13 +388,21 @@ details summary[role=button]:not(.outline)::after {
[type=reset],
[type=checkbox],
[type=radio],
[type=file]),
:host(:not([data-theme=dark])) input:is([type=submit],
[type=button],
[type=reset],
[type=checkbox],
[type=radio],
[type=file]) {
--pico-form-element-focus-color: var(--pico-primary-focus);
}
@media only screen and (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--pico-background-color: #13171f;
:root:not([data-theme]),
:host(:not([data-theme])) {
color-scheme: dark;
--pico-background-color: rgb(19, 22.5, 30.5);
--pico-color: #c2c7d0;
--pico-text-selection-color: rgba(144, 158, 190, 0.1875);
--pico-muted-color: #7b8495;
@ -427,7 +437,7 @@ details summary[role=button]:not(.outline)::after {
--pico-contrast-hover-underline: var(--pico-contrast-hover);
--pico-contrast-focus: rgba(207, 213, 226, 0.25);
--pico-contrast-inverse: #000;
--pico-box-shadow: 0.0145rem 0.029rem 0.174rem rgba(7, 9, 12, 0.01698), 0.0335rem 0.067rem 0.402rem rgba(7, 9, 12, 0.024), 0.0625rem 0.125rem 0.75rem rgba(7, 9, 12, 0.03), 0.1125rem 0.225rem 1.35rem rgba(7, 9, 12, 0.036), 0.2085rem 0.417rem 2.502rem rgba(7, 9, 12, 0.04302), 0.5rem 1rem 6rem rgba(7, 9, 12, 0.06), 0 0 0 0.0625rem rgba(7, 9, 12, 0.015);
--pico-box-shadow: 0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698), 0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024), 0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03), 0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036), 0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302), 0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06), 0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);
--pico-h1-color: #f0f1f3;
--pico-h2-color: #e0e3e7;
--pico-h3-color: #c2c7d0;
@ -437,31 +447,31 @@ details summary[role=button]:not(.outline)::after {
--pico-mark-background-color: #014063;
--pico-mark-color: #fff;
--pico-ins-color: #62af9a;
--pico-del-color: #ce7e7b;
--pico-del-color: rgb(205.5, 126, 123);
--pico-blockquote-border-color: var(--pico-muted-border-color);
--pico-blockquote-footer-color: var(--pico-muted-color);
--pico-button-box-shadow: 0 0 0 rgba(0, 0, 0, 0);
--pico-button-hover-box-shadow: 0 0 0 rgba(0, 0, 0, 0);
--pico-table-border-color: var(--pico-muted-border-color);
--pico-table-row-stripped-background-color: rgba(111, 120, 135, 0.0375);
--pico-code-background-color: #1a1f28;
--pico-code-background-color: rgb(26, 30.5, 40.25);
--pico-code-color: #8891a4;
--pico-code-kbd-background-color: var(--pico-color);
--pico-code-kbd-color: var(--pico-background-color);
--pico-form-element-background-color: #1c212c;
--pico-form-element-background-color: rgb(28, 33, 43.5);
--pico-form-element-selected-background-color: #2a3140;
--pico-form-element-border-color: #2a3140;
--pico-form-element-color: #e0e3e7;
--pico-form-element-placeholder-color: #8891a4;
--pico-form-element-active-background-color: #1a1f28;
--pico-form-element-active-background-color: rgb(26, 30.5, 40.25);
--pico-form-element-active-border-color: var(--pico-primary-border);
--pico-form-element-focus-color: var(--pico-primary-border);
--pico-form-element-disabled-opacity: 0.5;
--pico-form-element-invalid-border-color: #964a50;
--pico-form-element-invalid-active-border-color: #b7403b;
--pico-form-element-invalid-border-color: rgb(149.5, 74, 80);
--pico-form-element-invalid-active-border-color: rgb(183.25, 63.5, 59);
--pico-form-element-invalid-focus-color: var(--pico-form-element-invalid-active-border-color);
--pico-form-element-valid-border-color: #2a7b6f;
--pico-form-element-valid-active-border-color: #16896a;
--pico-form-element-valid-active-border-color: rgb(22, 137, 105.5);
--pico-form-element-valid-focus-color: var(--pico-form-element-valid-active-border-color);
--pico-switch-background-color: #333c4e;
--pico-switch-checked-background-color: var(--pico-primary-background);
@ -479,43 +489,55 @@ details summary[role=button]:not(.outline)::after {
--pico-card-background-color: #181c25;
--pico-card-border-color: var(--pico-card-background-color);
--pico-card-box-shadow: var(--pico-box-shadow);
--pico-card-sectioning-background-color: #1a1f28;
--pico-card-sectioning-background-color: rgb(26, 30.5, 40.25);
--pico-dropdown-background-color: #181c25;
--pico-dropdown-border-color: #202632;
--pico-dropdown-box-shadow: var(--pico-box-shadow);
--pico-dropdown-color: var(--pico-color);
--pico-dropdown-hover-background-color: #202632;
--pico-loading-spinner-opacity: 0.5;
--pico-modal-overlay-background-color: rgba(8, 9, 10, 0.75);
--pico-modal-overlay-background-color: rgba(7.5, 8.5, 10, 0.75);
--pico-progress-background-color: #202632;
--pico-progress-color: var(--pico-primary-background);
--pico-tooltip-background-color: var(--pico-contrast-background);
--pico-tooltip-color: var(--pico-contrast-inverse);
--pico-icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
--pico-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");
color-scheme: dark;
--pico-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");
}
:root:not([data-theme]) input:is([type=submit],
[type=button],
[type=reset],
[type=checkbox],
[type=radio],
[type=file]),
:host(:not([data-theme])) input:is([type=submit],
[type=button],
[type=reset],
[type=checkbox],
[type=radio],
[type=file]) {
--pico-form-element-focus-color: var(--pico-primary-focus);
}
:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after {
:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after,
:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after {
filter: brightness(0);
}
:root:not([data-theme]) [aria-busy=true]:not(input, select, textarea).contrast:is(button,
[type=submit],
[type=button],
[type=reset],
[role=button]):not(.outline)::before,
:host(:not([data-theme])) [aria-busy=true]:not(input, select, textarea).contrast:is(button,
[type=submit],
[type=button],
[type=reset],
[role=button]):not(.outline)::before {
filter: brightness(0);
}
}
[data-theme=dark] {
--pico-background-color: #13171f;
color-scheme: dark;
--pico-background-color: rgb(19, 22.5, 30.5);
--pico-color: #c2c7d0;
--pico-text-selection-color: rgba(144, 158, 190, 0.1875);
--pico-muted-color: #7b8495;
@ -550,7 +572,7 @@ details summary[role=button]:not(.outline)::after {
--pico-contrast-hover-underline: var(--pico-contrast-hover);
--pico-contrast-focus: rgba(207, 213, 226, 0.25);
--pico-contrast-inverse: #000;
--pico-box-shadow: 0.0145rem 0.029rem 0.174rem rgba(7, 9, 12, 0.01698), 0.0335rem 0.067rem 0.402rem rgba(7, 9, 12, 0.024), 0.0625rem 0.125rem 0.75rem rgba(7, 9, 12, 0.03), 0.1125rem 0.225rem 1.35rem rgba(7, 9, 12, 0.036), 0.2085rem 0.417rem 2.502rem rgba(7, 9, 12, 0.04302), 0.5rem 1rem 6rem rgba(7, 9, 12, 0.06), 0 0 0 0.0625rem rgba(7, 9, 12, 0.015);
--pico-box-shadow: 0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698), 0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024), 0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03), 0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036), 0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302), 0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06), 0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);
--pico-h1-color: #f0f1f3;
--pico-h2-color: #e0e3e7;
--pico-h3-color: #c2c7d0;
@ -560,31 +582,31 @@ details summary[role=button]:not(.outline)::after {
--pico-mark-background-color: #014063;
--pico-mark-color: #fff;
--pico-ins-color: #62af9a;
--pico-del-color: #ce7e7b;
--pico-del-color: rgb(205.5, 126, 123);
--pico-blockquote-border-color: var(--pico-muted-border-color);
--pico-blockquote-footer-color: var(--pico-muted-color);
--pico-button-box-shadow: 0 0 0 rgba(0, 0, 0, 0);
--pico-button-hover-box-shadow: 0 0 0 rgba(0, 0, 0, 0);
--pico-table-border-color: var(--pico-muted-border-color);
--pico-table-row-stripped-background-color: rgba(111, 120, 135, 0.0375);
--pico-code-background-color: #1a1f28;
--pico-code-background-color: rgb(26, 30.5, 40.25);
--pico-code-color: #8891a4;
--pico-code-kbd-background-color: var(--pico-color);
--pico-code-kbd-color: var(--pico-background-color);
--pico-form-element-background-color: #1c212c;
--pico-form-element-background-color: rgb(28, 33, 43.5);
--pico-form-element-selected-background-color: #2a3140;
--pico-form-element-border-color: #2a3140;
--pico-form-element-color: #e0e3e7;
--pico-form-element-placeholder-color: #8891a4;
--pico-form-element-active-background-color: #1a1f28;
--pico-form-element-active-background-color: rgb(26, 30.5, 40.25);
--pico-form-element-active-border-color: var(--pico-primary-border);
--pico-form-element-focus-color: var(--pico-primary-border);
--pico-form-element-disabled-opacity: 0.5;
--pico-form-element-invalid-border-color: #964a50;
--pico-form-element-invalid-active-border-color: #b7403b;
--pico-form-element-invalid-border-color: rgb(149.5, 74, 80);
--pico-form-element-invalid-active-border-color: rgb(183.25, 63.5, 59);
--pico-form-element-invalid-focus-color: var(--pico-form-element-invalid-active-border-color);
--pico-form-element-valid-border-color: #2a7b6f;
--pico-form-element-valid-active-border-color: #16896a;
--pico-form-element-valid-active-border-color: rgb(22, 137, 105.5);
--pico-form-element-valid-focus-color: var(--pico-form-element-valid-active-border-color);
--pico-switch-background-color: #333c4e;
--pico-switch-checked-background-color: var(--pico-primary-background);
@ -602,21 +624,20 @@ details summary[role=button]:not(.outline)::after {
--pico-card-background-color: #181c25;
--pico-card-border-color: var(--pico-card-background-color);
--pico-card-box-shadow: var(--pico-box-shadow);
--pico-card-sectioning-background-color: #1a1f28;
--pico-card-sectioning-background-color: rgb(26, 30.5, 40.25);
--pico-dropdown-background-color: #181c25;
--pico-dropdown-border-color: #202632;
--pico-dropdown-box-shadow: var(--pico-box-shadow);
--pico-dropdown-color: var(--pico-color);
--pico-dropdown-hover-background-color: #202632;
--pico-loading-spinner-opacity: 0.5;
--pico-modal-overlay-background-color: rgba(8, 9, 10, 0.75);
--pico-modal-overlay-background-color: rgba(7.5, 8.5, 10, 0.75);
--pico-progress-background-color: #202632;
--pico-progress-color: var(--pico-primary-background);
--pico-tooltip-background-color: var(--pico-contrast-background);
--pico-tooltip-color: var(--pico-contrast-inverse);
--pico-icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
--pico-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");
color-scheme: dark;
--pico-icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");
}
[data-theme=dark] input:is([type=submit],
[type=button],
@ -661,7 +682,8 @@ progress,
vertical-align: inherit;
}
:where(:root) {
:where(:root),
:where(:host) {
-webkit-tap-highlight-color: transparent;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
@ -1229,7 +1251,8 @@ img {
fill: currentColor;
}
svg:not(:root) {
svg:not(:root),
svg:not(:host) {
overflow: hidden;
}
@ -1244,7 +1267,8 @@ samp {
font-family: var(--pico-font-family);
}
pre code {
pre code,
pre samp {
font-size: inherit;
font-family: inherit;
}
@ -1256,7 +1280,8 @@ pre {
pre,
code,
kbd {
kbd,
samp {
border-radius: var(--pico-border-radius);
background: var(--pico-code-background-color);
color: var(--pico-code-color);
@ -1265,7 +1290,8 @@ kbd {
}
code,
kbd {
kbd,
samp {
display: inline-block;
padding: 0.375rem;
}
@ -1275,7 +1301,8 @@ pre {
margin-bottom: var(--pico-spacing);
overflow-x: auto;
}
pre > code {
pre > code,
pre > samp {
display: block;
padding: var(--pico-spacing);
background: none;
@ -1302,7 +1329,7 @@ figure figcaption {
}
/**
* Miscs
* Misc
*/
hr {
height: 0;
@ -2059,7 +2086,7 @@ details.dropdown {
position: relative;
border-bottom: none;
}
details.dropdown summary::after,
details.dropdown > summary::after,
details.dropdown > button::after,
details.dropdown > a::after {
display: block;
@ -2079,7 +2106,7 @@ nav details.dropdown {
margin-bottom: 0;
}
details.dropdown summary:not([role]) {
details.dropdown > summary:not([role]) {
height: calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);
padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);
border: var(--pico-border-width) solid var(--pico-form-element-border-color);
@ -2091,22 +2118,22 @@ details.dropdown summary:not([role]) {
user-select: none;
transition: background-color var(--pico-transition), border-color var(--pico-transition), color var(--pico-transition), box-shadow var(--pico-transition);
}
details.dropdown summary:not([role]):active, details.dropdown summary:not([role]):focus {
details.dropdown > summary:not([role]):active, details.dropdown > summary:not([role]):focus {
border-color: var(--pico-form-element-active-border-color);
background-color: var(--pico-form-element-active-background-color);
}
details.dropdown summary:not([role]):focus {
details.dropdown > summary:not([role]):focus {
box-shadow: 0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color);
}
details.dropdown summary:not([role]):focus-visible {
details.dropdown > summary:not([role]):focus-visible {
outline: none;
}
details.dropdown summary:not([role])[aria-invalid=false] {
details.dropdown > summary:not([role])[aria-invalid=false] {
--pico-form-element-border-color: var(--pico-form-element-valid-border-color);
--pico-form-element-active-border-color: var(--pico-form-element-valid-focus-color);
--pico-form-element-focus-color: var(--pico-form-element-valid-focus-color);
}
details.dropdown summary:not([role])[aria-invalid=true] {
details.dropdown > summary:not([role])[aria-invalid=true] {
--pico-form-element-border-color: var(--pico-form-element-invalid-border-color);
--pico-form-element-active-border-color: var(--pico-form-element-invalid-focus-color);
--pico-form-element-focus-color: var(--pico-form-element-invalid-focus-color);
@ -2116,18 +2143,18 @@ nav details.dropdown {
display: inline;
margin: calc(var(--pico-nav-element-spacing-vertical) * -1) 0;
}
nav details.dropdown summary::after {
nav details.dropdown > summary::after {
transform: rotate(0deg) translateX(0rem);
}
nav details.dropdown summary:not([role]) {
nav details.dropdown > summary:not([role]) {
height: calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);
padding: calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal);
}
nav details.dropdown summary:not([role]):focus-visible {
nav details.dropdown > summary:not([role]):focus-visible {
box-shadow: 0 0 0 var(--pico-outline-width) var(--pico-primary-focus);
}
details.dropdown summary + ul {
details.dropdown > summary + ul {
display: flex;
z-index: 99;
position: absolute;
@ -2147,23 +2174,23 @@ details.dropdown summary + ul {
opacity: 0;
transition: opacity var(--pico-transition), transform 0s ease-in-out 1s;
}
details.dropdown summary + ul[dir=rtl] {
details.dropdown > summary + ul[dir=rtl] {
right: 0;
left: auto;
}
details.dropdown summary + ul li {
details.dropdown > summary + ul li {
width: 100%;
margin-bottom: 0;
padding: calc(var(--pico-form-element-spacing-vertical) * 0.5) var(--pico-form-element-spacing-horizontal);
list-style: none;
}
details.dropdown summary + ul li:first-of-type {
details.dropdown > summary + ul li:first-of-type {
margin-top: calc(var(--pico-form-element-spacing-vertical) * 0.5);
}
details.dropdown summary + ul li:last-of-type {
details.dropdown > summary + ul li:last-of-type {
margin-bottom: calc(var(--pico-form-element-spacing-vertical) * 0.5);
}
details.dropdown summary + ul li a {
details.dropdown > summary + ul li a {
display: block;
margin: calc(var(--pico-form-element-spacing-vertical) * -0.5) calc(var(--pico-form-element-spacing-horizontal) * -1);
padding: calc(var(--pico-form-element-spacing-vertical) * 0.5) var(--pico-form-element-spacing-horizontal);
@ -2173,27 +2200,27 @@ details.dropdown summary + ul li a {
text-decoration: none;
text-overflow: ellipsis;
}
details.dropdown summary + ul li a:hover, details.dropdown summary + ul li a:focus, details.dropdown summary + ul li a:active, details.dropdown summary + ul li a:focus-visible, details.dropdown summary + ul li a[aria-current]:not([aria-current=false]) {
details.dropdown > summary + ul li a:hover, details.dropdown > summary + ul li a:focus, details.dropdown > summary + ul li a:active, details.dropdown > summary + ul li a:focus-visible, details.dropdown > summary + ul li a[aria-current]:not([aria-current=false]) {
background-color: var(--pico-dropdown-hover-background-color);
}
details.dropdown summary + ul li label {
details.dropdown > summary + ul li label {
width: 100%;
}
details.dropdown summary + ul li:has(label):hover {
details.dropdown > summary + ul li:has(label):hover {
background-color: var(--pico-dropdown-hover-background-color);
}
details.dropdown[open] summary {
details.dropdown[open] > summary {
margin-bottom: 0;
}
details.dropdown[open] summary + ul {
details.dropdown[open] > summary + ul {
transform: scaleY(1);
opacity: 1;
transition: opacity var(--pico-transition), transform 0s ease-in-out 0s;
}
details.dropdown[open] summary::before {
details.dropdown[open] > summary::before {
display: block;
z-index: 1;
position: fixed;
@ -2340,10 +2367,10 @@ label > details.dropdown {
/**
* Loading ([aria-busy=true])
*/
[aria-busy=true]:not(input, select, textarea, html) {
[aria-busy=true]:not(input, select, textarea, html, form) {
white-space: nowrap;
}
[aria-busy=true]:not(input, select, textarea, html)::before {
[aria-busy=true]:not(input, select, textarea, html, form)::before {
display: inline-block;
width: 1em;
height: 1em;
@ -2353,10 +2380,10 @@ label > details.dropdown {
content: "";
vertical-align: -0.125em;
}
[aria-busy=true]:not(input, select, textarea, html):not(:empty)::before {
[aria-busy=true]:not(input, select, textarea, html, form):not(:empty)::before {
margin-inline-end: calc(var(--pico-spacing) * 0.5);
}
[aria-busy=true]:not(input, select, textarea, html):empty {
[aria-busy=true]:not(input, select, textarea, html, form):empty {
text-align: center;
}
@ -2372,7 +2399,8 @@ a[aria-busy=true] {
/**
* Modal (<dialog>)
*/
:root {
:root,
:host {
--pico-scrollbar-width: 0px;
}
@ -2396,43 +2424,43 @@ dialog {
background-color: var(--pico-modal-overlay-background-color);
color: var(--pico-color);
}
dialog article {
dialog > article {
width: 100%;
max-height: calc(100vh - var(--pico-spacing) * 2);
margin: var(--pico-spacing);
overflow: auto;
}
@media (min-width: 576px) {
dialog article {
dialog > article {
max-width: 95%;
}
}
@media (min-width: 768px) {
dialog article {
dialog > article {
max-width: 95%;
}
}
dialog article > header > * {
dialog > article > header > * {
margin-bottom: 0;
}
dialog article > header .close, dialog article > header :is(a, button)[rel=prev] {
dialog > article > header .close, dialog > article > header :is(a, button)[rel=prev] {
margin: 0;
margin-left: var(--pico-spacing);
padding: 0;
float: right;
}
dialog article > footer {
dialog > article > footer {
text-align: right;
}
dialog article > footer button,
dialog article > footer [role=button] {
dialog > article > footer button,
dialog > article > footer [role=button] {
margin-bottom: 0;
}
dialog article > footer button:not(:first-of-type),
dialog article > footer [role=button]:not(:first-of-type) {
dialog > article > footer button:not(:first-of-type),
dialog > article > footer [role=button]:not(:first-of-type) {
margin-left: calc(var(--pico-spacing) * 0.5);
}
dialog article .close, dialog article :is(a, button)[rel=prev] {
dialog > article .close, dialog > article :is(a, button)[rel=prev] {
display: block;
width: 1rem;
height: 1rem;
@ -2448,7 +2476,7 @@ dialog article .close, dialog article :is(a, button)[rel=prev] {
opacity: 0.5;
transition: opacity var(--pico-transition);
}
dialog article .close:is([aria-current]:not([aria-current=false]), :hover, :active, :focus), dialog article :is(a, button)[rel=prev]:is([aria-current]:not([aria-current=false]), :hover, :active, :focus) {
dialog > article .close:is([aria-current]:not([aria-current=false]), :hover, :active, :focus), dialog > article :is(a, button)[rel=prev]:is([aria-current]:not([aria-current=false]), :hover, :active, :focus) {
opacity: 1;
}
dialog:not([open]), dialog[open=false] {
@ -2671,7 +2699,7 @@ progress::-moz-progress-bar {
[data-tooltip] {
position: relative;
}
[data-tooltip]:not(a, button, input) {
[data-tooltip]:not(a, button, input, [role=button]) {
border-bottom: 1px dotted;
text-decoration: none;
cursor: help;
@ -3220,6 +3248,28 @@ fieldset.vault-unlock {
padding: var(--pico-spacing) 0;
}
article.user-group {
background-color: var(--pico-form-element-background-color);
}
hgroup ol, hgroup ul {
margin-block: calc(var(--pico-spacing) / 2);
}
@media (max-width: 1536px) {
.grid-auto-cols {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: calc(var(--pico-spacing) / 2);
}
}
@media (max-width: 1280px) {
.grid-auto-cols {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: calc(var(--pico-spacing) / 2);
}
}
@media (max-width: 1024px) {
.split-grid.grid {
gap: calc(var(--pico-spacing) / 2);
@ -3231,17 +3281,9 @@ fieldset.vault-unlock {
}
.grid-auto-cols {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
gap: calc(var(--pico-spacing) / 2);
}
.grid-auto-cols .grid-span-all {
grid-column: 1/-1;
}
.grid-auto-cols > article {
margin-bottom: 0;
padding-bottom: var(--pico-form-element-spacing-vertical);
--pico-border-radius: .5rem;
}
th, td {
--pico-spacing: 0.75rem;
}
@ -3260,13 +3302,6 @@ fieldset.vault-unlock {
grid-template-columns: repeat(1, 1fr);
gap: calc(var(--pico-spacing) / 2);
}
.grid-auto-cols .grid-span-all {
grid-column: 1/-1;
}
.grid-auto-cols > article {
margin-bottom: 0;
--pico-border-radius: .5rem;
}
}
.user-group {
opacity: 0;

View File

@ -101,6 +101,8 @@
$sm-breakpoint: map-get(map-get($breakpoints, sm), breakpoint);
$md-breakpoint: map-get(map-get($breakpoints, md), breakpoint);
$lg-breakpoint: map-get(map-get($breakpoints, lg), breakpoint);
$xl-breakpoint: map-get(map-get($breakpoints, xl), breakpoint);
$xxl-breakpoint: map-get(map-get($breakpoints, xxl), breakpoint);
small {
font-size: .875rem;
@ -353,9 +355,27 @@ fieldset.vault-unlock {
}
article.user-group {
background-color: var(--pico-form-element-background-color);
}
hgroup ol, hgroup ul {
margin-block: calc(var(--pico-spacing)/2);
}
@media (max-width: $xxl-breakpoint) {
.grid-auto-cols {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: calc(var(--pico-spacing)/2);
}
}
@media (max-width: $xl-breakpoint) {
.grid-auto-cols {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: calc(var(--pico-spacing)/2);
}
}
@media (max-width: $lg-breakpoint) {
.split-grid.grid {
gap: calc(var(--pico-spacing)/2);
@ -367,17 +387,9 @@ article.user-group {
}
.grid-auto-cols {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
gap: calc(var(--pico-spacing)/2);
}
.grid-auto-cols .grid-span-all {
grid-column: 1 / -1;
}
.grid-auto-cols > article {
margin-bottom: 0;
padding-bottom: var(--pico-form-element-spacing-vertical);
--pico-border-radius: .5rem;
}
th, td {
--pico-spacing: 0.75rem;
}
@ -396,13 +408,6 @@ article.user-group {
grid-template-columns: repeat(1, 1fr);
gap: calc(var(--pico-spacing)/2);
}
.grid-auto-cols .grid-span-all {
grid-column: 1 / -1;
}
.grid-auto-cols > article {
margin-bottom: 0;
--pico-border-radius: .5rem;
}
}
.user-group {
@ -504,3 +509,4 @@ fieldset.keypair, fieldset.tresor {
}
}
}

View File

@ -51,6 +51,9 @@
<li>
<a href="#" hx-get="/system/users">Users</a>
</li>
<li>
<a href="#" hx-get="/system/groups">Groups</a>
</li>
<li>
<a href="#" hx-get="/system/logs">Logs</a>
</li>

View File

@ -0,0 +1,49 @@
{% if not request.headers.get("Hx-Request") %}
{% extends "base.html" %}
{% endif %}
{% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul>
<li>System</li>
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Users</a></li>
</ul>
</nav>
{% endblock breadcrumb %}
{% block body %}
<h4>Groups</h4>
<div class="grid split-grid">
<article class="hide-below-lg">
<hgroup>
<h5>Groups object</h5>
<p>Create an object by defining a unique name.</p>
</hgroup>
<form hx-disable _="on submit
halt the event
add @disabled to <fieldset/> in me
if $names does not contain #group.value and #group.value is not empty
append #group.value to $names
render #group-template with (name: #group.value, members: [], newGroup: true)
put the result at the end of #user-groups
call htmx.process(#user-groups)
add @hidden to .no-user-groups
transition .user-group's opacity to 1 over 175ms
end
remove @disabled from <fieldset/> in me
end">
<fieldset>
<input autocomplete="off" id="group" name="group" type="text" placeholder="New group name" />
<button type="submit">Create</button>
</fieldset>
</form>
</article>
<article>
{% include "system/includes/users/groups.html" %}
</article>
</div>
{% endblock body %}

View File

@ -31,12 +31,14 @@
set group to closest .user-group to event.target
set value of <[name=name]/> in group to value of <[name=new_name]/> in group
if <[name=members] option:checked/> in group is empty
log event.deta
add @hidden to group
settle
remove group
trigger deleteName
else
add @hidden to .user-group-unsaved in group
remove .user-group-new
end
end
on change
@ -53,31 +55,12 @@
</mark>
</p>
<section>
<form hx-disable _="on submit
halt the event
add @disabled to <fieldset/> in me
if $names does not contain #group.value and #group.value is not empty
append #group.value to $names
render #group-template with (name: #group.value, members: [])
put the result at the end of #user-groups
call htmx.process(#user-groups)
add @hidden to .no-user-groups
transition .user-group's opacity to 1 over 175ms
end
remove @disabled from <fieldset/> in me
end">
<fieldset>
<input autocomplete="off" id="group" name="group" type="text" placeholder="New group name" />
<button type="submit">Create</button>
</fieldset>
</form>
</section>
<template id="group-template">
@set hidden to "hidden" unless members is empty
@if members is not empty or newGroup
@set hidden to `hidden`
@end
<article class="user-group">
<form hx-patch="/system/users/groups">
<form hx-patch="/system/groups">
<input type="hidden" name="name" value="${name}"/>
<fieldset data-loading-disable>
<label>Group name</label>
@ -98,14 +81,26 @@
{% endfor %}
</select>
</fieldset>
@if newGroup
<small class="color-orange-400 user-group-new"
_="on change from closest <form/>
if <[name=members] option:checked/> in event.target is not empty
remove me
end">❗ New group, select members and save group to apply</small>
@end
<small class="color-yellow-200 user-group-unsaved" ${hidden}>⚠️ unsaved</small>
<div class="grid-space-between">
<a href="#" _="install confirmButton
on confirmedButton
set selectedIndex of <select/> in closest .user-group to -1
set selectedIndex of <select/> in closest <form/> to -1
trigger submit on closest <form/>
end">Remove</a>
<button data-loading-disable
<button data-loading-disable _="on click
if (selectedIndex of <select/> in closest <form/>) < 0
trigger notification(level: 'validationError', message: 'Missing members', duration: 3000, locations: ['members'])
halt the event
exit
end"
class="button-green user-group-unsaved"
${hidden}
type="submit">

View File

@ -0,0 +1,31 @@
<hgroup>
<h6>Pending logins</h6>
{% if not data.tokens["LOGIN"] %}
<p>No pending requests</p>
{% else %}
<p>
<ol>
{% for token, token_data in data.tokens["LOGIN"].items() %}
<li><b>{{ token_data.intention }}</b> on <small _="init js return new Date('{{ token_data.created }}').toLocaleString() end then put result into me">{{ token_data.created }}</small></li>
{% endfor %}
</ol>
</p>
{% endif %}
</hgroup>
<hr>
<hgroup>
<h6>Pending registrations</h6>
{% if not data.tokens["REGISTER"] %}
<p>No pending requests</p>
{% else %}
<p>
<ol>
{% for token, token_data in data.tokens["REGISTER"].items() %}
<li><b>{{ token_data.intention }}</b> on <small _="init js return new Date('{{ token_data.created }}').toLocaleString() end then put result into me">{{ token_data.created }}</small></li>
{% endfor %}
</ol>
</p>
{% endif %}
</hgroup>

View File

@ -1,5 +1,5 @@
<tr
hx-on:click="event.target.nodeName!=='INPUT'?(this.nextElementSibling.toggleAttribute('hidden'),this.nextElementSibling.hidden?null:this.scrollIntoView({behavior: 'smooth'})):null"
hx-on:click="event.target.nodeName!=='INPUT'?this.nextElementSibling.toggleAttribute('hidden'):null"
hx-disinherit="*"
hx-get="/system/users/{{ user.id }}"
hx-trigger="htmx:afterRequest[event.detail.successful==true] from:next .user-details"

View File

@ -13,20 +13,23 @@
{% block body %}
<h4>Users and groups</h4>
<h4>Users</h4>
<section>
<h5>Groups</h5>
{% include "system/includes/users/groups.html" %}
</section>
<hr>
<details class="show-below-lg">
<summary role="button" class="button-slate-800">Pending logins and registrations</summary>
<article>
{% include "system/includes/users/pending_tokens.html" %}
</article>
</details>
<div class="grid split-grid">
<article class="hide-below-lg">
{% include "system/includes/users/pending_tokens.html" %}
</article>
<article id="system-users-full-table">
<h5>Users</h5>
{% include "system/includes/users/table.html" %}
</article>
</div>
{% endblock body %}

View File

@ -4,6 +4,7 @@ from pydantic import constr
ACCEPT_LANGUAGES = ["en", "de"]
WEBAUTHN_CHALLENGE_TIMEOUT = 30 # seconds
AUTH_REQUEST_TIMEOUT = 300 # seconds
REGISTER_REQUEST_TIMEOUT = 300 # seconds
TABLE_PAGE_SIZE = 20
TRUSTED_PROXIES = ["127.0.0.1", "::1"]
TEMPLATES_AUTO_RELOAD = True

2
ctrl
View File

@ -23,7 +23,7 @@ parser.add_argument(
)
parser.add_argument(
"-t",
"--confirm_token",
"--confirm-token",
action="store_true",
help="Generate a token when prompted by the application",
)