Signed-off-by: apeters <apeters@korves.net>
This commit is contained in:
apeters 2025-06-03 11:40:56 +00:00
parent 3f3a7ba2d5
commit 519dbc73c6
39 changed files with 3544 additions and 595 deletions

View File

@ -1,18 +1,25 @@
from components.database import IN_MEMORY_DB from components.database import IN_MEMORY_DB
from components.models import UUID, validate_call from components.models import UUID, validate_call, constr
from components.utils import ensure_list from components.utils import ensure_list
from components.logs import logger
@validate_call @validate_call
def buster(bust_uuid: UUID | list[UUID]): def buster(
bust_uuid: UUID
| list[UUID]
| list[constr(pattern=r"^[0-9a-fA-F]+$", min_length=16)]
| constr(pattern=r"^[0-9a-fA-F]+$", min_length=16)
):
bust_uuids = ensure_list(bust_uuid) bust_uuids = ensure_list(bust_uuid)
for bust_uuid in bust_uuids: for bust_uuid in bust_uuids:
bust_uuid = str(bust_uuid) bust_uuid = str(bust_uuid)
for user_id in IN_MEMORY_DB["CACHE"]["MODELS"]: for user_id in IN_MEMORY_DB["CACHE"]["MODELS"]:
cached_keys = list(IN_MEMORY_DB["CACHE"]["MODELS"][user_id].keys()) cached_keys = list(IN_MEMORY_DB["CACHE"]["MODELS"][user_id].keys())
if bust_uuid in cached_keys: if bust_uuid in cached_keys:
if bust_uuid in IN_MEMORY_DB["CACHE"]["MODELS"][user_id]: if bust_uuid in IN_MEMORY_DB["CACHE"]["MODELS"][user_id]:
logger.debug(f"Cache buster 🧹 USER:{user_id};ITEM:{bust_uuid}")
del IN_MEMORY_DB["CACHE"]["MODELS"][user_id][bust_uuid] del IN_MEMORY_DB["CACHE"]["MODELS"][user_id][bust_uuid]
for user_id in IN_MEMORY_DB["CACHE"]["FORMS"]: for user_id in IN_MEMORY_DB["CACHE"]["FORMS"]:

View File

@ -137,13 +137,6 @@ class Cluster:
db_params = evaluate_db_params(ticket) db_params = evaluate_db_params(ticket)
async with TinyDB(**db_params) as db: async with TinyDB(**db_params) as db:
if not table in db.tables():
await self.send_command(
CritErrors.NO_SUCH_TABLE.response,
peer_meta["name"],
ticket=ticket,
)
else:
if not ticket in self._session_patched_tables: if not ticket in self._session_patched_tables:
self._session_patched_tables[ticket] = set() self._session_patched_tables[ticket] = set()
@ -176,9 +169,7 @@ class Cluster:
ticket=ticket, ticket=ticket,
) )
break break
db.table(table).upsert( db.table(table).upsert(Document(b, doc_id=doc_id))
Document(b, doc_id=doc_id)
)
else: # if no break occured, continue else: # if no break occured, continue
for doc_id, doc in diff["added"].items(): for doc_id, doc in diff["added"].items():
db.table(table).insert( db.table(table).insert(
@ -199,9 +190,7 @@ class Cluster:
) )
db.table(table).truncate() db.table(table).truncate()
for doc_id, doc in insert_data.items(): for doc_id, doc in insert_data.items():
db.table(table).insert( db.table(table).insert(Document(doc, doc_id=doc_id))
Document(doc, doc_id=doc_id)
)
await self.send_command( await self.send_command(
"ACK", peer_meta["name"], ticket=ticket "ACK", peer_meta["name"], ticket=ticket

View File

@ -27,10 +27,36 @@ TINYDB_PARAMS = {
"indent": 2, "indent": 2,
"sort_keys": True, "sort_keys": True,
} }
SYSTEM_CACHE_ID = "00000000-0000-0000-0000-000000000000"
ANONYMOUS_CACHE_ID = "99999999-9999-9999-9999-999999999999"
IN_MEMORY_DB = dict() IN_MEMORY_DB = dict()
IN_MEMORY_DB["SESSION_VALIDATED"] = dict()
IN_MEMORY_DB["WS_CONNECTIONS"] = dict()
IN_MEMORY_DB["CACHE"] = {
"MODELS": {
SYSTEM_CACHE_ID: dict(),
ANONYMOUS_CACHE_ID: dict(),
},
"FORMS": dict(),
}
IN_MEMORY_DB["APP_LOGS_FULL_PULL"] = dict()
IN_MEMORY_DB["PROMOTE_USERS"] = set()
IN_MEMORY_DB["TOKENS"] = {
"REGISTER": dict(),
"LOGIN": dict(),
}
CTX_TICKET = contextvars.ContextVar("CTX_TICKET", default=None) CTX_TICKET = contextvars.ContextVar("CTX_TICKET", default=None)
if not os.path.exists("database/main") or os.path.getsize("database/main") == 0:
os.makedirs(os.path.dirname("database/main"), exist_ok=True)
with open("database/main", "w") as f:
f.write("{}")
os.chmod("database/main", 0o600)
def evaluate_db_params(ticket: str | None = None): def evaluate_db_params(ticket: str | None = None):
db_params = copy(TINYDB_PARAMS) db_params = copy(TINYDB_PARAMS)
transaction_file = ( transaction_file = (

View File

@ -24,7 +24,6 @@ class ConnectionStatus(Enum):
class CritErrors(Enum): class CritErrors(Enum):
NOT_READY = "CRIT:NOT_READY" NOT_READY = "CRIT:NOT_READY"
NO_SUCH_TABLE = "CRIT:NO_SUCH_TABLE"
TABLE_HASH_MISMATCH = "CRIT:TABLE_HASH_MISMATCH" TABLE_HASH_MISMATCH = "CRIT:TABLE_HASH_MISMATCH"
CANNOT_APPLY = "CRIT:CANNOT_APPLY" CANNOT_APPLY = "CRIT:CANNOT_APPLY"
NOTHING_TO_COMMIT = "CRIT:NOTHING_TO_COMMIT" NOTHING_TO_COMMIT = "CRIT:NOTHING_TO_COMMIT"

View File

@ -25,42 +25,6 @@ class AuthToken(BaseModel):
) )
class Credential(BaseModel):
id: Annotated[str, AfterValidator(lambda x: bytes.fromhex(x))] | bytes
public_key: Annotated[str, AfterValidator(lambda x: bytes.fromhex(x))] | bytes
friendly_name: constr(strip_whitespace=True, min_length=1)
last_login: str
sign_count: int
transports: list[AuthenticatorTransport] | None = []
active: bool
updated: str
created: str
@field_serializer("id", "public_key")
def serialize_bytes_to_hex(self, v: bytes, _info):
return v.hex() if isinstance(v, bytes) else v
class AddCredential(BaseModel):
id: Annotated[bytes, AfterValidator(lambda x: x.hex())] | str
public_key: Annotated[bytes, AfterValidator(lambda x: x.hex())] | str
sign_count: int
friendly_name: str = "New passkey"
transports: list[AuthenticatorTransport] | None = []
active: bool = True
last_login: str = ""
@computed_field
@property
def created(self) -> str:
return utc_now_as_str()
@computed_field
@property
def updated(self) -> str:
return utc_now_as_str()
class UserProfile(BaseModel): class UserProfile(BaseModel):
model_config = ConfigDict(validate_assignment=True) model_config = ConfigDict(validate_assignment=True)
@ -102,6 +66,8 @@ class UserProfile(BaseModel):
k in tresor.keys() k in tresor.keys()
for k in ["public_key_pem", "wrapped_private_key", "iv", "salt"] for k in ["public_key_pem", "wrapped_private_key", "iv", "salt"]
) )
else:
v = None
return v return v
except: except:
raise PydanticCustomError( raise PydanticCustomError(
@ -113,7 +79,7 @@ class UserProfile(BaseModel):
tresor: str | None = Field( tresor: str | None = Field(
default=None, default=None,
json_schema_extra={ json_schema_extra={
"title": "Personal tresor", "title": "🔐 Personal tresor",
"type": "tresor", "type": "tresor",
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"', "input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
"form_id": f"tresor-{str(uuid4())}", "form_id": f"tresor-{str(uuid4())}",
@ -163,15 +129,52 @@ class UserProfile(BaseModel):
updated: str | None = None updated: str | None = None
class Credential(BaseModel):
id: Annotated[str, AfterValidator(lambda x: bytes.fromhex(x))] | bytes
public_key: Annotated[str, AfterValidator(lambda x: bytes.fromhex(x))] | bytes
friendly_name: constr(strip_whitespace=True, min_length=1)
last_login: str
sign_count: int
transports: list[AuthenticatorTransport] | None = []
active: bool
updated: str
created: str
@field_serializer("id", "public_key")
def serialize_bytes_to_hex(self, v: bytes, _info):
return v.hex() if isinstance(v, bytes) else v
class CredentialAdd(BaseModel):
id: Annotated[str, AfterValidator(lambda x: bytes.fromhex(x))] | bytes
public_key: Annotated[str, AfterValidator(lambda x: bytes.fromhex(x))] | bytes
sign_count: int
friendly_name: constr(strip_whitespace=True, min_length=1) = "New passkey"
transports: list[AuthenticatorTransport] | None = []
active: bool = True
last_login: str = ""
@computed_field
@property
def created(self) -> str:
return utc_now_as_str()
@computed_field
@property
def updated(self) -> str:
return utc_now_as_str()
@field_serializer("id", "public_key")
def serialize_bytes_to_hex(self, v: bytes, _info):
return v.hex() if isinstance(v, bytes) else v
class User(BaseModel): class User(BaseModel):
model_config = ConfigDict(validate_assignment=True) model_config = ConfigDict(validate_assignment=True)
id: Annotated[str, AfterValidator(lambda v: str(UUID(v)))] id: Annotated[str, AfterValidator(lambda v: str(UUID(v)))]
login: constr(strip_whitespace=True, min_length=1) login: constr(strip_whitespace=True, min_length=1)
credentials: dict[str, Credential] | Annotated[ credentials: list[Credential | CredentialAdd] = []
str | list[str],
AfterValidator(lambda v: ensure_list(v)),
] = {}
acl: Annotated[ acl: Annotated[
Literal[*USER_ACLS] | list[Literal[*USER_ACLS]], Literal[*USER_ACLS] | list[Literal[*USER_ACLS]],
AfterValidator(lambda v: ensure_list(v)), AfterValidator(lambda v: ensure_list(v)),
@ -221,7 +224,6 @@ class UserAdd(BaseModel):
class UserPatch(BaseModel): class UserPatch(BaseModel):
login: str | None = None login: str | None = None
acl: str | list = [] acl: str | list = []
credentials: str | list = []
groups: str | list | None = None groups: str | list | None = None
@computed_field @computed_field

View File

@ -9,6 +9,7 @@ from components.web.utils.quart import current_app, session
from components.utils import ensure_list, merge_models from components.utils import ensure_list, merge_models
from components.cache import buster from components.cache import buster
from components.database import * from components.database import *
from components.database import ANONYMOUS_CACHE_ID
@validate_call @validate_call
@ -20,10 +21,7 @@ async def get(
get_objects = ObjectIdList(object_id=object_id).object_id get_objects = ObjectIdList(object_id=object_id).object_id
db_params = evaluate_db_params() db_params = evaluate_db_params()
if current_app and session.get("id"): user_id = session["id"] if current_app and session.get("id") else ANONYMOUS_CACHE_ID
user_id = session["id"]
else:
user_id = "99999999-9999-9999-9999-999999999999"
if not user_id in IN_MEMORY_DB["CACHE"]["MODELS"]: if not user_id in IN_MEMORY_DB["CACHE"]["MODELS"]:
IN_MEMORY_DB["CACHE"]["MODELS"][user_id] = dict() IN_MEMORY_DB["CACHE"]["MODELS"][user_id] = dict()

View File

@ -1,5 +1,5 @@
from components.models.users import ( from components.models.users import (
AddCredential, CredentialAdd,
CredentialPatch, CredentialPatch,
Credential, Credential,
User, User,
@ -14,16 +14,10 @@ from components.models.users import (
) )
from components.utils import merge_models from components.utils import merge_models
from components.database import * from components.database import *
from components.database import SYSTEM_CACHE_ID
from components.cache import buster from components.cache import buster
def _create_credentials_mapping(credentials: dict):
user_credentials = dict()
for c in credentials:
user_credentials.update({c["id"]: Credential.model_validate(c)})
return user_credentials
@validate_call @validate_call
async def what_id(login: str): async def what_id(login: str):
db_params = evaluate_db_params() db_params = evaluate_db_params()
@ -48,38 +42,20 @@ async def create(data: dict):
insert_data = create_user.model_dump(mode="json") insert_data = create_user.model_dump(mode="json")
db.table("users").insert(insert_data) db.table("users").insert(insert_data)
for user_id in IN_MEMORY_DB["CACHE"]["FORMS"].copy():
if "users" in IN_MEMORY_DB["CACHE"]["FORMS"][user_id]:
del IN_MEMORY_DB["CACHE"]["FORMS"][user_id]["users"]
return insert_data["id"] return insert_data["id"]
@validate_call @validate_call
async def get(user_id: UUID, join_credentials: bool = True): async def get(user_id: UUID):
db_params = evaluate_db_params() db_params = evaluate_db_params()
system_id = "00000000-0000-0000-0000-000000000000"
if not IN_MEMORY_DB["CACHE"]["MODELS"].get(system_id):
IN_MEMORY_DB["CACHE"]["MODELS"][system_id] = dict()
async with TinyDB(**db_params) as db: async with TinyDB(**db_params) as db:
if not str(user_id) in IN_MEMORY_DB["CACHE"]["MODELS"][system_id]: if not str(user_id) in IN_MEMORY_DB["CACHE"]["MODELS"][SYSTEM_CACHE_ID]:
print( IN_MEMORY_DB["CACHE"]["MODELS"][SYSTEM_CACHE_ID][
User.model_validate(db.table("users").get(Query().id == str(user_id)))
)
IN_MEMORY_DB["CACHE"]["MODELS"][system_id][
str(user_id) str(user_id)
] = User.model_validate(db.table("users").get(Query().id == str(user_id))) ] = User.model_validate(db.table("users").get(Query().id == str(user_id)))
user = IN_MEMORY_DB["CACHE"]["MODELS"][system_id][str(user_id)].copy() user = IN_MEMORY_DB["CACHE"]["MODELS"][SYSTEM_CACHE_ID][str(user_id)].copy()
credentials = db.table("credentials").search(
(Query().id.one_of(user.credentials))
)
if join_credentials:
user.credentials = _create_credentials_mapping(credentials)
return user return user
@ -87,7 +63,7 @@ async def get(user_id: UUID, join_credentials: bool = True):
@validate_call @validate_call
async def delete(user_id: UUID): async def delete(user_id: UUID):
db_params = evaluate_db_params() db_params = evaluate_db_params()
user = await get(user_id=user_id, join_credentials=False) user = await get(user_id=user_id)
if not user: if not user:
raise ValueError("name", "The provided user does not exist") raise ValueError("name", "The provided user does not exist")
@ -96,8 +72,7 @@ async def delete(user_id: UUID):
if len(db.table("users").all()) == 1: if len(db.table("users").all()) == 1:
raise ValueError("name", "Cannot delete last user") raise ValueError("name", "Cannot delete last user")
db.table("credentials").remove(Query().id.one_of(user.credentials)) db.table("users").remove(Query().id == str(user_id))
deleted = db.table("users").remove(Query().id == str(user_id))
buster(user.id) buster(user.id)
return user.id return user.id
@ -105,18 +80,18 @@ async def delete(user_id: UUID):
@validate_call @validate_call
async def create_credential(user_id: UUID, data: dict): async def create_credential(user_id: UUID, data: dict):
db_params = evaluate_db_params() db_params = evaluate_db_params()
credential = AddCredential.model_validate(data) credential = CredentialAdd.model_validate(data)
user = await get(user_id=user_id, join_credentials=False) user = await get(user_id=user_id)
if not user: if not user:
raise ValueError("name", "The provided user does not exist") raise ValueError("name", "The provided user does not exist")
async with TinyDB(**db_params) as db: async with TinyDB(**db_params) as db:
db.table("credentials").insert(credential.model_dump(mode="json")) user.credentials.append(credential)
user.credentials.append(credential.id)
db.table("users").update( db.table("users").update(
{"credentials": user.credentials}, {"credentials": user.model_dump(mode="json")["credentials"]},
Query().id == str(user_id), Query().id == str(user_id),
) )
buster(user.id)
return credential.id return credential.id
@ -125,25 +100,35 @@ async def delete_credential(
user_id: UUID, hex_id: constr(pattern=r"^[0-9a-fA-F]+$", min_length=2) user_id: UUID, hex_id: constr(pattern=r"^[0-9a-fA-F]+$", min_length=2)
): ):
db_params = evaluate_db_params() db_params = evaluate_db_params()
user = await get(user_id=user_id, join_credentials=False) user = await get(user_id=user_id)
if not user: if not user:
raise ValueError("name", "The provided user does not exist") raise ValueError("name", "The provided user does not exist")
async with TinyDB(**db_params) as db: matched_user_credential = next(
if hex_id in user.credentials: (c for c in user.credentials if c.id == bytes.fromhex(hex_id)), None
user.credentials.remove(hex_id)
db.table("credentials").remove(Query().id == hex_id)
db.table("users").update(
{"credentials": user.credentials}, Query().id == str(user_id)
) )
if not matched_user_credential:
raise ValueError(
"hex_id",
"The provided credential ID was not found in user context",
)
async with TinyDB(**db_params) as db:
user.credentials.remove(matched_user_credential)
db.table("users").update(
{"credentials": user.model_dump(mode="json")["credentials"]},
Query().id == str(user_id),
)
buster(user_id)
return hex_id return hex_id
@validate_call @validate_call
async def patch(user_id: UUID, data: dict): async def patch(user_id: UUID, data: dict):
db_params = evaluate_db_params() db_params = evaluate_db_params()
user = await get(user_id=user_id, join_credentials=False) user = await get(user_id=user_id)
if not user: if not user:
raise ValueError("name", "The provided user does not exist") raise ValueError("name", "The provided user does not exist")
@ -161,15 +146,10 @@ async def patch(user_id: UUID, data: dict):
): ):
raise ValueError("login", "The provided login name exists") raise ValueError("login", "The provided login name exists")
orphaned_credentials = [
c for c in user.credentials if c not in patched_user.credentials
]
db.table("users").update( db.table("users").update(
patched_user.model_dump(mode="json"), patched_user.model_dump(mode="json"),
Query().id == str(user_id), Query().id == str(user_id),
) )
db.table("credentials").remove(Query().id.one_of(orphaned_credentials))
buster(user.id) buster(user.id)
return user.id return user.id
@ -177,7 +157,7 @@ async def patch(user_id: UUID, data: dict):
@validate_call @validate_call
async def patch_profile(user_id: UUID, data: dict): async def patch_profile(user_id: UUID, data: dict):
db_params = evaluate_db_params() db_params = evaluate_db_params()
user = await get(user_id=user_id, join_credentials=False) user = await get(user_id=user_id)
if not user: if not user:
raise ValueError("name", "The provided user does not exist") raise ValueError("name", "The provided user does not exist")
@ -192,6 +172,7 @@ async def patch_profile(user_id: UUID, data: dict):
{"profile": patched_user_profile.model_dump(mode="json")}, {"profile": patched_user_profile.model_dump(mode="json")},
Query().id == str(user_id), Query().id == str(user_id),
) )
buster(user.id)
return user_id return user_id
@ -200,35 +181,42 @@ async def patch_credential(
user_id: UUID, hex_id: constr(pattern=r"^[0-9a-fA-F]+$", min_length=2), data: dict user_id: UUID, hex_id: constr(pattern=r"^[0-9a-fA-F]+$", min_length=2), data: dict
): ):
db_params = evaluate_db_params() db_params = evaluate_db_params()
user = await get(user_id=user_id, join_credentials=True) user = await get(user_id=user_id)
if not user: if not user:
raise ValueError("name", "The provided user does not exist") raise ValueError("name", "The provided user does not exist")
if hex_id not in user.credentials: matched_user_credential = next(
(c for c in user.credentials if c.id == bytes.fromhex(hex_id)), None
)
if not matched_user_credential:
raise ValueError( raise ValueError(
"hex_id", "hex_id",
"The provided credential ID was not found in user context", "The provided credential ID was not found in user context",
) )
patch_data = CredentialPatch.model_validate(data) user.credentials.remove(matched_user_credential)
patched_credential = merge_models( patched_credential = merge_models(
user.credentials[hex_id], matched_user_credential,
patch_data, CredentialPatch.model_validate(data),
exclude_strategies=["exclude_override_none"], exclude_strategies=["exclude_override_none"],
) )
user.credentials.append(patched_credential)
async with TinyDB(**db_params) as db: async with TinyDB(**db_params) as db:
db.table("credentials").update( db.table("users").update(
patched_credential.model_dump(mode="json"), Query().id == hex_id {"credentials": user.model_dump(mode="json")["credentials"]},
Query().id == str(user_id),
) )
buster(user_id)
return hex_id return hex_id
@validate_call @validate_call
async def search( async def search(name: constr(strip_whitespace=True, min_length=0)):
name: constr(strip_whitespace=True, min_length=0), join_credentials: bool = True
):
db_params = evaluate_db_params() db_params = evaluate_db_params()
def search_name(s): def search_name(s):
@ -237,6 +225,4 @@ async def search(
async with TinyDB(**db_params) as db: async with TinyDB(**db_params) as db:
matches = db.table("users").search(Query().login.test(search_name)) matches = db.table("users").search(Query().login.test(search_name))
return [ return [await get(user["id"]) for user in matches]
await get(user["id"], join_credentials=join_credentials) for user in matches
]

View File

@ -30,18 +30,6 @@ app.config["SECRET_KEY"] = defaults.SECRET_KEY
app.config["TEMPLATES_AUTO_RELOAD"] = defaults.TEMPLATES_AUTO_RELOAD app.config["TEMPLATES_AUTO_RELOAD"] = defaults.TEMPLATES_AUTO_RELOAD
app.config["SERVER_NAME"] = defaults.HOSTNAME app.config["SERVER_NAME"] = defaults.HOSTNAME
app.config["MOD_REQ_LIMIT"] = 10 app.config["MOD_REQ_LIMIT"] = 10
IN_MEMORY_DB["SESSION_VALIDATED"] = dict()
IN_MEMORY_DB["WS_CONNECTIONS"] = dict()
IN_MEMORY_DB["CACHE"] = {
"MODELS": dict(),
"FORMS": 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"]) modifying_request_limiter = asyncio.Semaphore(app.config["MOD_REQ_LIMIT"])

View File

@ -299,8 +299,7 @@ async def login_webauthn_options():
return validation_error([{"loc": ["login"], "msg": f"User is not available"}]) return validation_error([{"loc": ["login"], "msg": f"User is not available"}])
allow_credentials = [ allow_credentials = [
PublicKeyCredentialDescriptor(id=bytes.fromhex(c)) PublicKeyCredentialDescriptor(id=c.id) for c in user.credentials
for c in user.credentials.keys()
] ]
options = generate_authentication_options( options = generate_authentication_options(
@ -402,8 +401,7 @@ async def register_webauthn_options():
user = await get_user(user_id=session["id"]) user = await get_user(user_id=session["id"])
exclude_credentials = [ exclude_credentials = [
PublicKeyCredentialDescriptor(id=bytes.fromhex(c)) PublicKeyCredentialDescriptor(id=c.id) for c in user.credentials
for c in user.credentials.keys()
] ]
user_id = session["id"] user_id = session["id"]
@ -491,10 +489,9 @@ async def register_webauthn():
} }
try: try:
async with ClusterLock(["users", "credentials"], current_app): async with ClusterLock("users", current_app):
if not appending_passkey: if not appending_passkey:
user_id = await create_user(data={"login": login}) user_id = await create_user(data={"login": login})
await create_credential( await create_credential(
user_id=user_id, user_id=user_id,
data={ data={
@ -506,7 +503,7 @@ async def register_webauthn():
) )
except Exception as e: except Exception as e:
logger.error(e) logger.critical(e)
return trigger_notification( return trigger_notification(
level="error", level="error",
response_code=409, response_code=409,
@ -516,16 +513,12 @@ async def register_webauthn():
) )
if appending_passkey: if appending_passkey:
await ws_htmx(
session["login"],
"beforeend",
f'<div id="after-cred-add" hx-sync="abort" hx-trigger="load delay:1s" hx-target="#body-main" hx-get="/profile"></div>',
)
return trigger_notification( return trigger_notification(
level="success", level="success",
response_code=204, response_code=204,
title="New token registered", title="New token registered",
message="A new token was appended to your account and can now be used to login", message="A new token was appended to your account and can now be used to login",
additional_triggers={"appendCompleted": ""},
) )
return trigger_notification( return trigger_notification(
@ -566,10 +559,9 @@ async def auth_login_verify():
credential = parse_authentication_credential_json(json_body) credential = parse_authentication_credential_json(json_body)
matched_user_credential = None matched_user_credential = next(
for k, v in user.credentials.items(): (c for c in user.credentials if c.id == credential.raw_id), None
if bytes.fromhex(k) == credential.raw_id: )
matched_user_credential = v
if not matched_user_credential: if not matched_user_credential:
return trigger_notification( return trigger_notification(
@ -594,7 +586,7 @@ async def auth_login_verify():
if matched_user_credential.sign_count != 0: if matched_user_credential.sign_count != 0:
matched_user_credential.sign_count = verification.new_sign_count matched_user_credential.sign_count = verification.new_sign_count
async with ClusterLock("credentials", current_app): async with ClusterLock("users", current_app):
user_id = await what_id(login=login) user_id = await what_id(login=login)
await patch_credential( await patch_credential(
user_id=user_id, user_id=user_id,
@ -603,7 +595,7 @@ async def auth_login_verify():
) )
except Exception as e: except Exception as e:
logger.error(e) logger.critical(e)
return trigger_notification( return trigger_notification(
level="error", level="error",
response_code=409, response_code=409,

View File

@ -23,31 +23,27 @@ async def user_group():
assigned_to = [ assigned_to = [
u u
for u in await components.users.search(name="", join_credentials=False) for u in await components.users.search(name="")
if request_data.name in u.groups if request_data.name in u.groups
] ]
assign_to = [] assign_to = []
for user_id in request_data.members: for user_id in request_data.members:
assign_to.append( assign_to.append(await components.users.get(user_id=user_id))
await components.users.get(user_id=user_id, join_credentials=False)
)
_all = assigned_to + assign_to _all = assigned_to + assign_to
async with ClusterLock(["users", "credentials"], current_app): async with ClusterLock("users", current_app):
for user in _all: for user in _all:
user_dict = user.model_dump(mode="json") if request_data.name in user.groups:
if request_data.name in user_dict["groups"]: user.groups.remove(request_data.name)
user_dict["groups"].remove(request_data.name)
if ( if request_data.new_name not in user.groups and user in assign_to:
request_data.new_name not in user_dict["groups"] user.groups.append(request_data.new_name)
and user in assign_to
):
user_dict["groups"].append(request_data.new_name)
await components.users.patch(user_id=user.id, data=user_dict) await components.users.patch(
user_id=user.id, data=user.model_dump(mode="json")
)
return "", 204 return "", 204

View File

@ -8,8 +8,7 @@ blueprint = Blueprint("profile", __name__, url_prefix="/profile")
@blueprint.context_processor @blueprint.context_processor
def load_context(): def load_context():
context = dict() context = {"schemas": {"user_profile": UserProfile.model_json_schema()}}
context["schemas"] = {"user_profile": UserProfile.model_json_schema()}
return context return context
@ -25,14 +24,7 @@ async def user_profile_get():
name, message = e.args name, message = e.args
return validation_error([{"loc": [name], "msg": message}]) return validation_error([{"loc": [name], "msg": message}])
return await render_template( return await render_template("profile/profile.html", user=user)
"profile/profile.html",
data={
"user": user.dict(),
"keypair": None,
"credentials": user.credentials,
},
)
@blueprint.route("/edit", methods=["PATCH"]) @blueprint.route("/edit", methods=["PATCH"])
@ -68,7 +60,7 @@ async def user_profile_patch():
@acl("any") @acl("any")
async def patch_credential(credential_hex_id: str): async def patch_credential(credential_hex_id: str):
try: try:
async with ClusterLock("credentials", current_app): async with ClusterLock("users", current_app):
await components.users.patch_credential( await components.users.patch_credential(
user_id=session["id"], user_id=session["id"],
hex_id=credential_hex_id, hex_id=credential_hex_id,
@ -89,7 +81,7 @@ async def patch_credential(credential_hex_id: str):
@acl("any") @acl("any")
async def delete_credential(credential_hex_id: str): async def delete_credential(credential_hex_id: str):
try: try:
async with ClusterLock(["credentials", "users"], current_app): async with ClusterLock("users", current_app):
await components.users.delete_credential( await components.users.delete_credential(
user_id=session["id"], hex_id=credential_hex_id user_id=session["id"], hex_id=credential_hex_id
) )

View File

@ -33,7 +33,7 @@ async def logout():
async def ws(): async def ws():
while True: while True:
await websocket.send( await websocket.send(
f'<div class="no-text-decoration" data-tooltip="Connected" id="ws-indicator" hx-swap-oob="outerHTML">🟢</div>' f'<span class="no-text-decoration" data-tooltip="Connected" id="ws-indicator" hx-swap-oob="outerHTML">🟢</span>'
) )
data = await websocket.receive() data = await websocket.receive()
try: try:

View File

@ -11,8 +11,7 @@ blueprint = Blueprint("users", __name__, url_prefix="/system/users")
def load_context(): def load_context():
from components.models.users import UserProfile from components.models.users import UserProfile
context = dict() context = {"schemas": {"user_profile": UserProfile.model_json_schema()}}
context["schemas"] = {"user_profile": UserProfile.model_json_schema()}
return context return context
@ -28,7 +27,7 @@ async def get_user(user_id: str):
return validation_error([{"loc": [name], "msg": message}]) return validation_error([{"loc": [name], "msg": message}])
return await render_or_json( return await render_or_json(
"system/includes/users/row.html", request.headers, user=user.dict() "system/includes/users/row.html", request.headers, user=user
) )
@ -50,16 +49,14 @@ async def get_users():
return validation_error(e.errors()) return validation_error(e.errors())
if request.method == "POST": if request.method == "POST":
matched_users = [ matched_users = [m for m in await components.users.search(name=search_model.q)]
m.dict() for m in await components.users.search(name=search_model.q)
]
user_pages = [ user_pages = [
m m
for m in batch( for m in batch(
sorted( sorted(
matched_users, matched_users,
key=lambda x: x.get(sort_attr, "id"), key=lambda x: getattr(x, sort_attr, "id"),
reverse=sort_reverse, reverse=sort_reverse,
), ),
page_size, page_size,
@ -97,7 +94,7 @@ async def delete_user(user_id: str | None = None):
user_ids = request.form_parsed.get("id") user_ids = request.form_parsed.get("id")
try: try:
async with ClusterLock(["users", "credentials"], current_app): async with ClusterLock("users", current_app):
for user_id in ensure_list(user_ids): for user_id in ensure_list(user_ids):
await components.users.delete(user_id=user_id) await components.users.delete(user_id=user_id)
@ -119,7 +116,7 @@ async def delete_user(user_id: str | None = None):
@acl("system") @acl("system")
async def patch_user_credential(user_id: str, hex_id: str): async def patch_user_credential(user_id: str, hex_id: str):
try: try:
async with ClusterLock("credentials", current_app): async with ClusterLock("users", current_app):
await components.users.patch_credential( await components.users.patch_credential(
user_id=user_id, user_id=user_id,
hex_id=hex_id, hex_id=hex_id,
@ -139,6 +136,26 @@ async def patch_user_credential(user_id: str, hex_id: str):
) )
@blueprint.route("/<user_id>/credential/<hex_id>", methods=["DELETE"])
@acl("system")
async def delete_user_credential(user_id: str, hex_id: str):
try:
async with ClusterLock("users", current_app):
await components.users.delete_credential(
user_id=user_id,
hex_id=hex_id,
)
except ValidationError as e:
return validation_error(e.errors())
return trigger_notification(
level="success",
response_code=204,
title="Credential deleted",
message="Credential was removed",
)
@blueprint.route("/patch", methods=["POST"]) @blueprint.route("/patch", methods=["POST"])
@blueprint.route("/<user_id>", methods=["PATCH"]) @blueprint.route("/<user_id>", methods=["PATCH"])
@acl("system") @acl("system")
@ -147,7 +164,7 @@ async def patch_user(user_id: str | None = None):
if not user_id: if not user_id:
user_id = request.form_parsed.get("id") user_id = request.form_parsed.get("id")
async with ClusterLock(["users", "credentials"], current_app): async with ClusterLock("users", current_app):
await components.users.patch(user_id=user_id, data=request.form_parsed) await components.users.patch(user_id=user_id, data=request.form_parsed)
await components.users.patch_profile( await components.users.patch_profile(
user_id=user_id, data=request.form_parsed.get("profile", {}) user_id=user_id, data=request.form_parsed.get("profile", {})

File diff suppressed because it is too large Load Diff

View File

@ -119,16 +119,19 @@ pre {
padding: calc(var(--pico-spacing)/2); padding: calc(var(--pico-spacing)/2);
} }
#nav-theme-toggle { nav[aria-label="breadcrumb"] span {
cursor:pointer !important; padding: var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);
margin-inline-start: calc(var(--pico-nav-link-spacing-horizontal) * -1);
} }
#nav-theme-bulb {
cursor:pointer !important;
text-decoration: none;
}
.dark { .dark {
filter: grayscale(100%); filter: grayscale(100%);
} }
.hi, .hi a {
font-size:1.1rem;
--pico-text-decoration: none;
}
table td article { table td article {
margin-bottom: var(--pico-spacing); margin-bottom: var(--pico-spacing);
@ -145,6 +148,10 @@ table td.created-modified {
table td.created-modified, table th.created-modified { table td.created-modified, table th.created-modified {
text-align: right; text-align: right;
} }
table td > a[role="button"],
table td > button {
padding: calc(var(--pico-form-element-spacing-vertical) / 2) calc(var(--pico-form-element-spacing-horizontal) / 2);
}
.no-text-decoration { .no-text-decoration {
text-decoration: none !important; text-decoration: none !important;
@ -155,10 +162,6 @@ table td.created-modified, table th.created-modified {
display: none; display: none;
} }
.help {
cursor:help;
}
.pointer { .pointer {
cursor:pointer; cursor:pointer;
} }
@ -265,15 +268,11 @@ table td.created-modified, table th.created-modified {
--pico-color: #{$fuchsia-100}; --pico-color: #{$fuchsia-100};
} }
.login-grid { article.login-mask {
display: grid; padding: calc(var(--pico-spacing)*2);
grid-template-columns: 20% 60% 20%; border-radius: 1.5rem;
grid-template-rows: 1fr;
} }
.login-register { grid-column-start: 2; }
thead th, thead td, tfoot th, tfoot td { thead th, thead td, tfoot th, tfoot td {
--pico-font-weight: 400; --pico-font-weight: 400;
} }
@ -323,7 +322,6 @@ dialog article {
align-items: baseline; align-items: baseline;
margin: calc(var(--pico-spacing) /2) auto; margin: calc(var(--pico-spacing) /2) auto;
} }
.grid-auto-cols { .grid-auto-cols {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
@ -350,10 +348,6 @@ nav details.dropdown {
width: max-content; width: max-content;
} }
fieldset.vault-unlock {
padding: var(--pico-spacing) 0;
}
article.user-group { article.user-group {
background-color: var(--pico-form-element-background-color); background-color: var(--pico-form-element-background-color);
} }
@ -429,6 +423,11 @@ fieldset.keypair, fieldset.tresor {
padding: var(--pico-spacing); padding: var(--pico-spacing);
} }
button#menu-vault-dialog-toggle {
padding: calc(var(--pico-form-element-spacing-vertical) / 1.5) calc(var(--pico-form-element-spacing-horizontal) / 1.5);
border-color: var(--pico-form-element-border-color);
--pico-border-radius: .5rem;
}
/////////////////////////////////////// ///////////////////////////////////////
// Generators for colors and breakpoints // Generators for colors and breakpoints
@ -468,31 +467,43 @@ fieldset.keypair, fieldset.tresor {
@each $color-key, $color-var in $colors { @each $color-key, $color-var in $colors {
@each $shade, $value in $color-var { @each $shade, $value in $color-var {
.color-#{"#{$color-key}"}-#{$shade} { .color-#{$color-key}-#{$shade} {
color: $value !important; color: $value !important;
} }
:is(button, [type="submit"], [type="button"], [role="button"]).button-#{"#{$color-key}"}-#{$shade},
[type="reset"].button-#{"#{$color-key}"}-#{$shade} { // Default filled button
:is(button, [type="submit"], [type="button"], [role="button"]).button-#{$color-key}-#{$shade},
[type="reset"].button-#{$color-key}-#{$shade} {
color: get-contrast-color($value); color: get-contrast-color($value);
border-color: $value; border-color: $value;
background-color: $value; background-color: $value;
} }
:is(a).color-#{"#{$color-key}"}-#{$shade} {
// Outline version overrides background and text color
:is(button, [type="submit"], [type="button"], [role="button"]).button-#{$color-key}-#{$shade}.outline,
[type="reset"].button-#{$color-key}-#{$shade}.outline {
background-color: transparent;
color: $value;
}
:is(a).color-#{$color-key}-#{$shade} {
text-decoration-color: $value !important; text-decoration-color: $value !important;
} }
} }
@if map-has-key($color-var, 500) { @if map-has-key($color-var, 500) {
.color-#{"#{$color-key}"} { .color-#{$color-key} {
@extend .color-#{"#{$color-key}"}-500; @extend .color-#{$color-key}-500;
} }
:is(button, [type="submit"], [type="button"], [role="button"]).button-#{"#{$color-key}"},
[type="reset"].button-#{"#{$color-key}"} { :is(button, [type="submit"], [type="button"], [role="button"]).button-#{$color-key},
@extend .button-#{"#{$color-key}"}-500; [type="reset"].button-#{$color-key} {
@extend .button-#{$color-key}-500;
} }
} }
} }
@each $size, $data in $breakpoints { @each $size, $data in $breakpoints {
$breakpoint: map-get($data, breakpoint); $breakpoint: map-get($data, breakpoint);
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {

View File

@ -150,28 +150,37 @@ end
behavior tresorToggle behavior tresorToggle
def setUnlocked def setUnlocked
get #vault-unlock-pin get #menu-dialog-vault-unlock-pin
add @disabled to it add @disabled to it
set its @placeholder to 'Tresor is unlocked' set its @placeholder to 'Tresor is unlocked'
set #vault-unlock's textContent to '🔓' get #menu-dialog-vault-unlock
set its textContent to 'Unlock'
remove @disabled from it
set #menu-vault-indicator's textContent to 'unlocked ✅'
end end
def setLocked def setLocked
get #vault-unlock-pin get #menu-dialog-vault-unlock-pin
remove @disabled from it remove @disabled from it
set its @placeholder to 'Tresor password' set its @placeholder to 'Tresor password'
set #vault-unlock's textContent to '🔐' get #menu-dialog-vault-unlock
set its textContent to 'Unlock'
remove @disabled from it
set #menu-vault-indicator's textContent to 'locked 🔒'
end end
def noTresor def noTresor
get #vault-unlock-pin get #menu-dialog-vault-unlock-pin
add @disabled to it add @disabled to it
set its @placeholder to 'No tresor available' set its @placeholder to 'No tresor available'
set #vault-unlock's textContent to '⛔' get #menu-dialog-vault-unlock
add @disabled to it
set its textContent to 'Not available'
set #menu-vault-indicator's textContent to 'not available ⛔'
end end
init init
if window.vault.isUnlocked() if window.vault.isUnlocked()
call setUnlocked() call setUnlocked()
else else
if #vault-unlock's @data-tresor != "" if #menu-dialog-vault-unlock's @data-tresor != ""
call setLocked() call setLocked()
else else
call noTresor() call noTresor()
@ -179,32 +188,33 @@ behavior tresorToggle
end end
end end
on profileUpdate from body on profileUpdate from body
exit unless #vault-unlock's @data-tresor == "" exit unless #menu-dialog-vault-unlock's @data-tresor == ""
set #vault-unlock's @data-tresor to (value of event.detail) set #menu-dialog-vault-unlock's @data-tresor to (value of event.detail)
call setLocked() call setLocked()
end end
on keydown[keyCode == 13] from #vault-unlock-pin on keydown[keyCode == 13] from #menu-dialog-vault-unlock-pin
trigger click on #vault-unlock unless #vault-unlock-pin's value is empty trigger click on #menu-dialog-vault-unlock unless #menu-dialog-vault-unlock-pin's value is empty
end end
on click from #vault-unlock on click from #menu-dialog-vault-unlock
halt the event halt the event
if not window.vault.isUnlocked() if not window.vault.isUnlocked()
exit unless value of #vault-unlock-pin throw "No PIN" unless value of #menu-dialog-vault-unlock-pin
call JSON.parse(#vault-unlock's @data-tresor) set keyData to the result call JSON.parse(#menu-dialog-vault-unlock's @data-tresor) set keyData to the result
call VaultUnlockPrivateKey(value of #vault-unlock-pin, keyData) call VaultUnlockPrivateKey(value of #menu-dialog-vault-unlock-pin, keyData)
call setUnlocked() call setUnlocked()
else else
call window.vault.lock() call window.vault.lock()
call setLocked() call setLocked()
end end
set value of #vault-unlock-pin to '' set value of #menu-dialog-vault-unlock-pin to ''
remove @open from closest <dialog/>
on exception(error) on exception(error)
trigger notification( trigger notification(
title: 'Tresor error', title: 'Tresor error',
level: 'validationError', level: 'validationError',
message: 'Could not unlock tresor, check your PIN', message: 'Could not unlock tresor, check your PIN',
duration: 3000, duration: 3000,
locations: ['vault-unlock-pin'] locations: ['menu-vault-unlock-pin']
) )
end end
end end
@ -213,7 +223,23 @@ behavior bodydefault
on htmx:wsError or htmx:wsClose on htmx:wsError or htmx:wsClose
set #ws-indicator's textContent to '⭕' set #ws-indicator's textContent to '⭕'
end end
init set :reloadCounter to 1 end
on forceReload
trigger notification(
title: 'Unlocked session',
level: 'user',
message: `Preventing window reload due to unlocked session (keep pressing to force reload)`,
duration: 2000,
locations: []
)
wait for a forceReload or 400ms
if result's type is 'forceReload'
increment :reloadCounter
log :reloadCounter
else
set :reloadCounter to 1
end
end
on keydown on keydown
exit unless window.vault.isUnlocked() exit unless window.vault.isUnlocked()
if navigator.platform.toUpperCase().indexOf('MAC') >= 0 if navigator.platform.toUpperCase().indexOf('MAC') >= 0
@ -222,16 +248,12 @@ behavior bodydefault
set ctrlOrCmd to event.ctrlKey set ctrlOrCmd to event.ctrlKey
end end
if (event.key is "F5" or (ctrlOrCmd and event.key.toLowerCase() === "r")) or ((ctrlOrCmd and event.shiftKey and event.key.toLowerCase() === "r") or (event.shiftKey and e.key === "F5")) if (event.key is "F5" or (ctrlOrCmd and event.key.toLowerCase() === "r")) or ((ctrlOrCmd and event.shiftKey and event.key.toLowerCase() === "r") or (event.shiftKey and e.key === "F5"))
trigger notification( trigger forceReload
title: 'Unlocked session', if :reloadCounter < 2
level: 'user',
message: 'Preventing window reload due to unlocked session',
duration: 2000,
locations: []
)
halt the event halt the event
end end
end end
end
on htmx:responseError on htmx:responseError
set status to event.detail.xhr.status set status to event.detail.xhr.status

View File

@ -26,6 +26,10 @@ htmx.on("body", "regCompleted", async function(evt){
htmx.ajax("GET", "/", "#body-main") htmx.ajax("GET", "/", "#body-main")
}) })
htmx.on("body", "appendCompleted", async function(evt){
htmx.ajax("GET", "/", "#body-main")
})
htmx.on("body", "startAuth", async function(evt){ htmx.on("body", "startAuth", async function(evt){
const { startAuthentication } = SimpleWebAuthnBrowser const { startAuthentication } = SimpleWebAuthnBrowser
var login_button = htmx.find("#authenticate") var login_button = htmx.find("#authenticate")

View File

@ -97,6 +97,14 @@ class UserCryptoVault {
this.keyPair = { privateKey, publicKey }; this.keyPair = { privateKey, publicKey };
} }
async exportPrivateKeyPEM() {
if (!this.keyPair?.publicKey || !this.keyPair?.privateKey) throw new Error("Vault not unlocked");
const pkcs8 = await crypto.subtle.exportKey("pkcs8", this.keyPair.privateKey);
const b64 = btoa(String.fromCharCode(...new Uint8Array(pkcs8)));
const lines = b64.match(/.{1,64}/g).join("\n");
return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`;
}
async encryptData(message) { async encryptData(message) {
if (!this.keyPair?.publicKey || !this.keyPair?.privateKey) throw new Error("Vault not unlocked"); if (!this.keyPair?.publicKey || !this.keyPair?.privateKey) throw new Error("Vault not unlocked");

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -5,17 +5,17 @@
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>Welcome 👋</li> <li><span>Welcome 👋</span></li>
<li>Login / Register</li> <li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Login or register</a></li>
</ul> </ul>
</nav> </nav>
{% endblock breadcrumb %} {% endblock breadcrumb %}
{% block body %} {% block body %}
<div class="login-grid"> <article class="login-mask">
{% include "auth/includes/login/login.html" %} {% include "auth/includes/login/login.html" %}
{% with hidden=True %} {% with hidden=True %}
{% include "auth/includes/register/register.html" %} {% include "auth/includes/register/register.html" %}
{% endwith %} {% endwith %}
</section> </article>
{% endblock body %} {% endblock body %}

View File

@ -2,7 +2,7 @@
"hidden" is used to include both login and register forms in main.html while only showing either/or "hidden" is used to include both login and register forms in main.html while only showing either/or
#} #}
<div id="login-form" class="login-register" {{ '.hidden' if hidden }}> <div id="login-form" class="login-register" {{ "hidden" if hidden }}>
<form <form
data-loading-disable data-loading-disable
hx-trigger="submit throttle:1s" hx-trigger="submit throttle:1s"

View File

@ -2,11 +2,12 @@
"hidden" is used to include both login and register forms in main.html while only showing either/or "hidden" is used to include both login and register forms in main.html while only showing either/or
#} #}
<form <div id="register-form" class="login-register" {{ "hidden" if hidden }}>
<form
data-loading-disable data-loading-disable
hx-trigger="submit throttle:1s" hx-trigger="submit throttle:1s"
hx-post="/auth/register/token" hx-post="/auth/register/token">
class="login-register" id="register-form" {{ 'hidden' if hidden }}> <fieldset>
<label for="webauthn-register">Pick a username</label> <label for="webauthn-register">Pick a username</label>
<input type="text" id="webauthn-register" name="login" <input type="text" id="webauthn-register" name="login"
autocomplete="off" autocomplete="off"
@ -14,10 +15,12 @@
autocapitalize="off" autocapitalize="off"
spellcheck="false" spellcheck="false"
required> required>
</fieldset>
<button type="submit" id="register">Next</button> <button type="submit" id="register">Next</button>
<hr> <hr>
<p> <p>
A token will be generated and needs to be validated via <i>command line</i>:<br> A token will be generated and needs to be validated via <b>command line</b>:
<code class="pointer" hx-on:click="!window.s?s=this.textContent:null;navigator.clipboard.writeText(s);this.textContent='Copied';setTimeout(()=>{this.textContent=s}, 1000)">./ctrl -t</code> <code class="pointer" hx-on:click="!window.s?s=this.textContent:null;navigator.clipboard.writeText(s);this.textContent='Copied';setTimeout(()=>{this.textContent=s}, 1000)">./ctrl -t</code>
</p> </p>
</form> </form>
</div>

View File

@ -3,17 +3,15 @@
hx-trigger="submit throttle:1s" hx-trigger="submit throttle:1s"
hx-post="/auth/register/webauthn/options"> hx-post="/auth/register/webauthn/options">
<input type="hidden" name="token" value="{{ token }}"> <input type="hidden" name="token" value="{{ token }}">
<article> <div>
<header> <h5>Your token: <mark>{{ token }}</mark></h5>
<mark>{{ token }}</mark>
</header>
<p> <p>
A token intention was saved. Please ask an administrator to verify your token.<br> A token intention was saved. Please ask an administrator to verify your token.<br>
After validation you will be asked to register a passkey. After validation you will be asked to register a passkey.
</p> </p>
<footer> <footer>
<fieldset> <fieldset>
<label for="confirmation_code">Token confirmation code</label> <label for="confirmation_code">Confirmation code</label>
<input type="text" id="confirmation_code" name="confirmation_code" <input type="text" id="confirmation_code" name="confirmation_code"
pattern="[0-9]*" pattern="[0-9]*"
autocomplete="off" autocomplete="off"
@ -25,9 +23,9 @@
hx-target="this" hx-target="this"
id="register" id="register"
hx-swap="innerHTML"> hx-swap="innerHTML">
Validate <small>and register</small> Register
</button> </button>
</fieldset> </fieldset>
</footer> </footer>
</article> </div>
</form> </form>

View File

@ -29,7 +29,7 @@
{% block menu %} {% block menu %}
{% include "includes/menu.html" %} {% include "includes/menu.html" %}
{% endblock %} {% endblock %}
<hr>
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"></nav> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"></nav>
{% endblock %} {% endblock %}

View File

@ -59,13 +59,16 @@ DATA FIELDS
</fieldset> </fieldset>
{% elif v.type == "tresor" %} {% elif v.type == "tresor" %}
{% if request.path == "/profile/" %} {% if request.path == "/profile/" %}
<details name="tresor">
<summary role="button">{{ v.title }}</summary>
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %} class="tresor"> <fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field" disabled{% endif %} class="tresor">
<label>{{ v.title }}</label>
<input id="{{ v.form_id }}" type="hidden" {{ v.input_extra|safe }} <input id="{{ v.form_id }}" type="hidden" {{ v.input_extra|safe }}
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}" name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
value="{% if current_data[k] == None %}{{ v.default }}{% else %}{{ current_data[k] }}{% endif %}" /> value="{% if current_data[k] %}{{ current_data[k] }}{% endif %}" />
{% if not current_data[k] %} {% if not current_data[k] %}
<legend>Setup tresor</legend>
<input form="_ignore" id="{{ v.form_id }}-password" name="{{ v.form_id }}-password" type="password" {{ v.input_extra|safe }} /> <input form="_ignore" id="{{ v.form_id }}-password" name="{{ v.form_id }}-password" type="password" {{ v.input_extra|safe }} />
<small>Password</small> <small>Password</small>
<input form="_ignore" id="{{ v.form_id }}-password2" name="{{ v.form_id }}-password2" type="password" {{ v.input_extra|safe }} /> <input form="_ignore" id="{{ v.form_id }}-password2" name="{{ v.form_id }}-password2" type="password" {{ v.input_extra|safe }} />
@ -88,6 +91,7 @@ DATA FIELDS
Setup encryption Setup encryption
</a> </a>
{% else %} {% else %}
<legend>Change tresor password</legend>
<input form="_ignore" id="old-{{ v.form_id }}" name="old-{{ v.form_id }}" type="password" {{ v.input_extra|safe }} /> <input form="_ignore" id="old-{{ v.form_id }}" name="old-{{ v.form_id }}" type="password" {{ v.input_extra|safe }} />
<small>Current password</small> <small>Current password</small>
<input form="_ignore" id="new-{{ v.form_id }}" name="new-{{ v.form_id }}" type="password" {{ v.input_extra|safe }} /> <input form="_ignore" id="new-{{ v.form_id }}" name="new-{{ v.form_id }}" type="password" {{ v.input_extra|safe }} />
@ -120,6 +124,7 @@ DATA FIELDS
</a> </a>
{% endif %} {% endif %}
</fieldset> </fieldset>
</details>
{% endif %} {% endif %}
{% elif v.type == "datalist" %} {% elif v.type == "datalist" %}
@ -243,7 +248,7 @@ DATA FIELDS
<p>No key pairs available</p> <p>No key pairs available</p>
{% else %} {% else %}
<fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field keypair" disabled{% endif %} class="keypair"> <fieldset data-loading-disable {% if k in system_fields and not "system" in session["acl"] %}class="system-field keypair" disabled{% endif %} class="keypair">
<label for="{{ v.form_id }}">{{ v.title }}</label> <legend>{{ v.title }}</legend>
<select {{ v.input_extra|safe }} <select {{ v.input_extra|safe }}
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}" name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
id="{{ v.form_id }}" id="{{ v.form_id }}"

View File

@ -1,13 +1,11 @@
<nav hx-target="#body-main"> <nav hx-target="#body-main">
<ul> <ul>
<li> <li>
<a href="#" hx-get="/" class="nav-logo"> <a href="#" hx-get="/">
{% include "ehlo.svg" %} {% include "logo.svg" %}
</a> </a>
<span aria-busy="true" data-loading></span>
</li> </li>
</ul> </ul>
<ul> <ul>
{% if not session["login"] %} {% if not session["login"] %}
<li> <li>
@ -90,34 +88,59 @@
{% if session["login"] %} {% if session["login"] %}
<div id="nav-sub-primary" hx-target="#body-main" class="grid-space-between"> <div hx-target="#body-main" class="grid-space-between">
<div class="no-text-wrap hi"> <a href="#" class="no-text-decoration"
👋 <b><a href="#" hx-get="/profile/">{{ session.get("login") or "guest" }}</a></b> id="menu-vault-dialog-toggle"
</div> _="on click
halt the event
add @open to #menu-vault-dialog
call #menu-dialog-vault-unlock-pin.focus()
end">
Tresor <span id="menu-vault-indicator"></span>
</a>
<div> <div>
{% for role in session.get("acl", []) %} {% for role in session.get("acl", []) %}
<mark>{{ role }}</mark> <mark>{{ role }}</mark>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<dialog id="menu-vault-dialog">
<article>
<header>
<h6>Manage tresor</h6>
</header>
<p>Enter your password below. The tresor will be kept unlocked until you refresh the window or lock it.</p>
<fieldset _="install tresorToggle">
<input hx-disable
id="menu-dialog-vault-unlock-pin"
name="menu-dialog-vault-unlock-pin"
type="password"
autocomplete="off"
autocorrect="off"
data-protonpass-ignore="true"
autocapitalize="off"
spellcheck="false"/>
<a role="button" href="#" id="menu-dialog-vault-unlock" data-tresor="{{ session.profile.tresor or "" }}">...</a>
</fieldset>
<footer>
<button _="on click remove @open from closest <dialog/>">Close</button>
</footer>
</article>
</dialog>
{% endif %} {% endif %}
<div id="nav-sub-secondary" hx-target="#body-main" class="grid-end"> <div hx-target="#body-main" class="grid-end">
<div id="ws-indicator" class="no-text-decoration" data-tooltip="{% if session["login"] %}Disconnected{% else %}Not logged in{% endif %}" hx-swap-oob="outerHTML"></div> <span aria-busy="true" data-loading></span>
{% if "system" in session.get("acl", []) %} <span id="ws-indicator"
<div id="enforce-dbupdate" hx-swap-oob="outerHTML"> {% if session["login"] %}
{% if ENFORCE_DBUPDATE %} class="no-text-decoration"
<button data-tooltip="Enforced database updates are enabled" data-tooltip="Disconnected"
class="button-red-800" {% else %}
id="enforce-dbupdate-button" class="no-text-decoration dark"
hx-get="/system/status" data-tooltip="Not logged in"
_="on load call countdownSeconds(me, {{ ENFORCE_DBUPDATE }}) end">
!!!
</button>
{% endif %} {% endif %}
</div> hx-swap-oob="outerHTML">⭕</span>
{% endif %} <span id="nav-theme-bulb"
<div id="nav-theme-toggle"
_="on updateTheme _="on updateTheme
if not localStorage.theme if not localStorage.theme
if window.matchMedia('(prefers-color-scheme: light)').matches if window.matchMedia('(prefers-color-scheme: light)').matches
@ -133,6 +156,7 @@
end end
init trigger updateTheme end init trigger updateTheme end
on click on click
halt the event
if I match .light if I match .light
set (@data-theme of <html/>) to 'dark' set (@data-theme of <html/>) to 'dark'
else else
@ -141,26 +165,23 @@
set localStorage.theme to (@data-theme of <html/>) set localStorage.theme to (@data-theme of <html/>)
toggle between .light and .dark toggle between .light and .dark
end end
">&#128161; Theme ">&#128161;
</div> </span>
</div> </div>
{% if session["login"] %} {% if "system" in session.get("acl", []) %}
<section> <div hx-target="#body-main" id="enforce-dbupdate" hx-swap-oob="outerHTML">
<hr> {% if ENFORCE_DBUPDATE %}
<fieldset _="install tresorToggle" role="group"> <button data-tooltip="Enforced database updates are enabled"
<input hx-disable class="button-red-800"
id="vault-unlock-pin" id="enforce-dbupdate-button"
name="vault-unlock-pin" hx-get="/system/status"
type="password" _="on load call countdownSeconds(me, {{ ENFORCE_DBUPDATE }}) end">
autocomplete="off" !!!
autocorrect="off" </button>
data-protonpass-ignore="true" {% endif %}
autocapitalize="off" </div>
spellcheck="false"/>
<a role="button" href="#" id="vault-unlock" data-tresor="{{ session.profile.tresor }}">...</a>
</fieldset>
</section>
{% endif %} {% endif %}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -5,7 +5,7 @@
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>Objects</li> <li><span>Objects</span></li>
<li><a href="#" hx-target="#body-main" hx-get="/objects/{{ request.view_args.get("object_type") }}">{{ request.view_args.get("object_type")|capitalize }}</a></li> <li><a href="#" hx-target="#body-main" hx-get="/objects/{{ request.view_args.get("object_type") }}">{{ request.view_args.get("object_type")|capitalize }}</a></li>
<li><a href="#" hx-target="#body-main" hx-get="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">{{ object.name }}</a></li> <li><a href="#" hx-target="#body-main" hx-get="/objects/{{ request.view_args.get("object_type") }}/{{ object.id }}">{{ object.name }}</a></li>
</ul> </ul>

View File

@ -5,7 +5,7 @@
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>Objects</li> <li><span>Objects</span></li>
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">{{ request.view_args.get("object_type")|capitalize }}</a></li> <li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">{{ request.view_args.get("object_type")|capitalize }}</a></li>
</ul> </ul>
</nav> </nav>
@ -13,13 +13,13 @@
{% block body %} {% block body %}
<h4>Manage {{ request.view_args.get("object_type") }}</h4> <h5>Manage {{ request.view_args.get("object_type") }}</h5>
<details class="show-below-lg"> <details class="show-below-lg">
<summary role="button" class="button-slate-800">Create object</summary> <summary role="button" class="button-slate-800">Create object</summary>
<article> <article>
<hgroup> <hgroup>
<h5>New object</h5> <h6>New object</h6>
<p>Create an object by defining a unique name.</p> <p>Create an object by defining a unique name.</p>
</hgroup> </hgroup>
{% include "objects/includes/create/" ~ request.view_args.get("object_type") ~ ".html" %} {% include "objects/includes/create/" ~ request.view_args.get("object_type") ~ ".html" %}
@ -29,7 +29,7 @@
<div class="grid split-grid"> <div class="grid split-grid">
<article class="hide-below-lg"> <article class="hide-below-lg">
<hgroup> <hgroup>
<h5>New object</h5> <h6>New object</h6>
<p>Create an object by defining a unique name.</p> <p>Create an object by defining a unique name.</p>
</hgroup> </hgroup>
{% include "objects/includes/create/" ~ request.view_args.get("object_type") ~ ".html" %} {% include "objects/includes/create/" ~ request.view_args.get("object_type") ~ ".html" %}

View File

@ -1,68 +1,48 @@
<article id="profile-authenticators"> <article id="profile-authenticators">
<h5>Passkeys</h5> <h5>Passkeys</h5>
<p>The authenticator that started the session is indicated as active.</p> <p>The authenticator that started the session is indicated as active.</p>
<div class="overflow-auto">
<table> <h6>Credentials</h6>
<thead> {% if not user.credentials %}
<tr> <i>No credentials available</i>
<th scope="col">Name</th> {% endif %}
<th scope="col">Last login</th> {% for credential in user.credentials %}
<th scope="col">Action</th> <fieldset id="profile-credential-{{ credential.id|hex }}"
<th scope="col" class="created-modified">Created / Updated</th> hx-trigger="htmx:afterRequest[event.detail.successful==true] from:#profile-credential-{{ credential.id|hex }}"
</tr>
</thead>
<tbody id="token-table-body">
{% for hex_id, credential_data in data.credentials.items() %}
<tr id="profile-credential-{{ hex_id }}"
hx-trigger="htmx:afterRequest[event.detail.successful==true] from:#profile-credential-{{ hex_id }}"
hx-target="this" hx-target="this"
hx-select="#profile-credential-{{ hex_id }}" hx-select="#profile-credential-{{ credential.id|hex }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-get="/profile/"> hx-get="/profile/">
<th scope="row"> {% if session["cred_id"] == credential.id|hex %}
{% if session["cred_id"] == hex_id %}
<mark>in use</mark> <mark>in use</mark>
{% endif %} {% endif %}
<span _="install inlineHtmxRename()" <span _="install inlineHtmxRename()"
contenteditable contenteditable
data-patch-parameter="friendly_name" data-patch-parameter="friendly_name"
spellcheck="false" spellcheck="false"
hx-patch="/profile/credential/{{ hex_id }}" hx-patch="/profile/credential/{{ credential.id|hex }}"
hx-trigger="editContent"> hx-trigger="editContent">
{{- credential_data.friendly_name or 'John Doe' -}} {{- credential.friendly_name or 'John Doe' -}}
</span> </span>
</th> <a href="#" hx-disinherit="*" class="{{ "color-red" if not credential.active else "color-green"}}"
<td> hx-patch="/profile/credential/{{ credential.id|hex }}"
{% if credential_data.last_login %} hx-vals='js:{"active": {{ "true" if not credential.active else "false"}}}'
<span _="init js return new Date('{{ credential_data.last_login }}').toLocaleString() end then put result into me">{{ credential_data.last_login }}</span> hx-params="active">{{ "[disabled]" if not credential.active else "[enabled]"}}</a>
{% endif %}
</td> <a href="#" hx-disinherit="*" class="color-red"
<td> hx-confirm="Delete passkey?"
<a href="#" role="button" class="button-red"
hx-confirm="Delete token?"
_="install confirmButton" _="install confirmButton"
hx-trigger="confirmedButton throttle:200ms" hx-trigger="confirmedButton throttle:200ms"
hx-delete="/profile/credential/{{ hex_id }}"> hx-delete="/profile/credential/{{ credential.id|hex }}">[remove]</a>
Remove <br>
</a> <small>Last Login:
<a href="#" role="button" class="{{ "outline" if not credential_data.active else ""}}" {% if credential.last_login %}
hx-patch="/profile/credential/{{ hex_id }}" <span class="" value="{{ credential.last_login }}" _="init set dt to my @value js(dt) return new Date(dt).toLocaleString() end then put result into me"></span>
hx-vals='js:{"active": {{ "true" if not credential_data.active else "false"}}}' {% else %}-
hx-params="active">
{{ "Disabled" if not credential_data.active else "Enabled"}}
</a>
</td>
<td class="created-modified">
<small _="init js return new Date('{{- credential_data.created -}}').toLocaleString() end then put result into me">{{- credential_data.created -}}</small>
{% if credential_data.created != credential_data.updated %}
<br>&#9999;&#65039; <small _="init js return new Date('{{- credential_data.updated -}}').toLocaleString() end then put result into me">{{- credential_data.updated -}}</small>
{% endif %} {% endif %}
</td> </small>
</tr> </fieldset>
{% endfor %} {% endfor %}
</tbody>
</table>
</div>
<button type="submit" hx-post="/auth/register/webauthn/options" <button type="submit" hx-post="/auth/register/webauthn/options"
data-loading-disable data-loading-disable

View File

@ -5,7 +5,7 @@
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>Profile</li> <li><span>Profile</span></li>
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">{{ session["login"] }}</a></li> <li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">{{ session["login"] }}</a></li>
</ul> </ul>
</nav> </nav>
@ -13,14 +13,14 @@
{% block body %} {% block body %}
<h4>Your data</h4> <h5>Your data</h5>
<article hx-trigger="htmx:afterRequest[event.detail.successful==true] from:#profile-form" hx-select="#profile-form" hx-get="/profile/" hx-target="#profile-form"> <article hx-trigger="htmx:afterRequest[event.detail.successful==true] from:#profile-form" hx-select="#profile-form" hx-get="/profile/" hx-target="#profile-form">
<h5>Profile</h5> <h6>Profile</h6>
<form hx-trigger="submit throttle:1s" hx-patch="/profile/edit" id="profile-form"> <form hx-trigger="submit throttle:1s" hx-patch="/profile/edit" id="profile-form">
{% with {% with
schema=schemas.user_profile, schema=schemas.user_profile,
current_data=data.user.profile current_data=user.profile
%} %}
{% include "includes/form_builder.html" %} {% include "includes/form_builder.html" %}
{% endwith %} {% endwith %}

View File

@ -10,6 +10,9 @@
data-layer-role="icon" data-layer-role="icon"
version="1.1" version="1.1"
id="main" id="main"
sodipodi:docname="logo.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs xmlns:svg="http://www.w3.org/2000/svg"><defs
@ -24,14 +27,14 @@
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
showgrid="false" showgrid="false"
inkscape:zoom="11.313709" inkscape:zoom="11.313709"
inkscape:cx="46.182912" inkscape:cx="23.378717"
inkscape:cy="33.45499" inkscape:cy="33.587571"
inkscape:window-width="2560" inkscape:window-width="1280"
inkscape:window-height="1355" inkscape:window-height="1312"
inkscape:window-x="0" inkscape:window-x="1280"
inkscape:window-y="0" inkscape:window-y="35"
inkscape:window-maximized="1" inkscape:window-maximized="0"
inkscape:current-layer="svg194" /> inkscape:current-layer="main" />
<path <path
class="st0" class="st0"
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd" style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -6,19 +6,19 @@
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>System</li> <li>System</li>
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Users</a></li> <li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Groups</a></li>
</ul> </ul>
</nav> </nav>
{% endblock breadcrumb %} {% endblock breadcrumb %}
{% block body %} {% block body %}
<h4>Groups</h4> <h5>Groups</h5>
<div class="grid split-grid"> <div class="grid split-grid">
<article class="hide-below-lg"> <article class="hide-below-lg">
<hgroup> <hgroup>
<h5>Groups object</h5> <h6>Groups object</h6>
<p>Create an object by defining a unique name.</p> <p>Create an object by defining a unique name.</p>
</hgroup> </hgroup>
<form hx-disable _="on submit <form hx-disable _="on submit

View File

@ -5,7 +5,7 @@
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>System</li> <li><span>System</span></li>
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Logs</a></li> <li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Logs</a></li>
</ul> </ul>
</nav> </nav>
@ -13,7 +13,7 @@
{% block body %} {% block body %}
<h4>System logs</h4> <h5>System logs</h5>
<div class="grid split-grid"> <div class="grid split-grid">
<article> <article>

View File

@ -5,7 +5,7 @@
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>System</li> <li><span>System</span></li>
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Settings</a></li> <li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Settings</a></li>
</ul> </ul>
</nav> </nav>
@ -13,7 +13,7 @@
{% block body %} {% block body %}
<h4>Settings</h4> <h5>Settings</h5>
<div class="grid split-grid"> <div class="grid split-grid">
<article> <article>

View File

@ -5,7 +5,7 @@
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>System</li> <li><span>System</span></li>
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Status</a></li> <li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Status</a></li>
</ul> </ul>
</nav> </nav>
@ -13,7 +13,7 @@
{% block body %} {% block body %}
<h4>System Status - <a href="#" class="no-text-decoration" <h5>System Status - <a href="#" class="no-text-decoration"
hx-on:click="!window.s?s=this.textContent:null;this.textContent='👍';setTimeout(()=>{this.textContent=s}, 1000)" hx-on:click="!window.s?s=this.textContent:null;this.textContent='👍';setTimeout(()=>{this.textContent=s}, 1000)"
hx-target="#system-status-cards" hx-target="#system-status-cards"
hx-select="#system-status-cards" hx-select="#system-status-cards"
@ -23,12 +23,12 @@
hx-vals=""> hx-vals="">
⟳ Refresh ⟳ Refresh
</a> </a>
</h4> </h5>
<div id="system-status"> <div id="system-status">
<section id="system-status-cards" class="grid-auto-cols"> <section id="system-status-cards" class="grid-auto-cols">
<article> <article>
<h5>{{ data.status.CLUSTER_PEERS_LOCAL.name }} 🏠</h5> <h6>{{ data.status.CLUSTER_PEERS_LOCAL.name }} 🏠</h6>
<p> <p>
<small>This node.</small> <small>This node.</small>
</p> </p>
@ -46,7 +46,7 @@
</article> </article>
{% for peer, peer_data in data.status.CLUSTER_PEERS_REMOTE_PEERS.items() %} {% for peer, peer_data in data.status.CLUSTER_PEERS_REMOTE_PEERS.items() %}
<article> <article>
<h5>{{ peer }} {% if peer == data.status.CLUSTER_PEERS_LOCAL.leader %}👑{% endif %}</h5> <h6>{{ peer }} {% if peer == data.status.CLUSTER_PEERS_LOCAL.leader %}👑{% endif %}</h6>
<ul> <ul>
{% for k, v in peer_data %} {% for k, v in peer_data %}
<li><span class="color-zinc-600">{{ k.split("_")|join(" ")|capitalize }}</span>: <li><span class="color-zinc-600">{{ k.split("_")|join(" ")|capitalize }}</span>:
@ -74,7 +74,7 @@
<button type="submit" <button type="submit"
hx-trigger="click queue:first" hx-trigger="click queue:first"
hx-confirm="This action will overwrite table data on nodes with a mismatching database, are you sure?" hx-confirm="This action will overwrite table data on nodes with a mismatching database, are you sure?"
hx-post="/system/cluster/db/enforce-updates" hx-vals='{"toggle": "on"}' hx-target="#nav-sub-secondary"> hx-post="/system/cluster/db/enforce-updates" hx-vals='{"toggle": "on"}' hx-target="#enforce-dbupdate">
🟢 Enforce database updates 🟢 Enforce database updates
</button> </button>
{% else %} {% else %}
@ -85,7 +85,7 @@
{% endif %} {% endif %}
{% if ENFORCE_DBUPDATE and request.headers.get("Hx-Request") %} {% if ENFORCE_DBUPDATE and request.headers.get("Hx-Request") %}
{# oob-swapping a button to menu #} {# oob-swapping a button to menu #}
<div id="enforce-dbupdate" hx-swap-oob="outerHTML"> <div hx-target="#body-main" id="enforce-dbupdate" hx-swap-oob="outerHTML">
<button data-tooltip="Enforced database updates are enabled" <button data-tooltip="Enforced database updates are enabled"
class="button-red-800" class="button-red-800"
id="enforce-dbupdate-button" id="enforce-dbupdate-button"

View File

@ -3,7 +3,7 @@
hx-patch="/system/users/{{ user.id }}"> hx-patch="/system/users/{{ user.id }}">
<article> <article>
<h5>General</h5> <h6>General</h6>
<fieldset> <fieldset>
<label>Login</label> <label>Login</label>
<input name="login" <input name="login"
@ -16,7 +16,7 @@
<article> <article>
<fieldset> <fieldset>
<h5>ACL</h5> <h6>ACL</h6>
{% for acl in USER_ACLS %} {% for acl in USER_ACLS %}
<input <input
role="switch" role="switch"
@ -33,38 +33,39 @@
</article> </article>
<article> <article>
<h5>Credentials</h5> <h6>Credentials</h6>
{% if not user.credentials %} {% if not user.credentials %}
<i>No credentials available</i> <i>No credentials available</i>
{% endif %} {% endif %}
{% for hex_id, credential_data in user.credentials.items() %} {% for credential in user.credentials|sort(attribute='friendly_name', reverse=false) %}
<section> <section>
<fieldset> <fieldset>
<input
type="checkbox"
name="credentials"
value="{{ hex_id }}"
hx-on:change="!this.checked?this.nextElementSibling.innerHTML='<span class=\'color-red\'>Will be deleted!</span>':this.nextElementSibling.innerHTML=''"
checked >
<span></span>
<span _="install inlineHtmxRename()" <span _="install inlineHtmxRename()"
contenteditable contenteditable
data-patch-parameter="friendly_name" data-patch-parameter="friendly_name"
spellcheck="false" spellcheck="false"
hx-patch="/system/users/{{ user.id }}/credential/{{ hex_id }}" hx-patch="/system/users/{{ user.id }}/credential/{{ credential.id|hex }}"
hx-trigger="editContent"> hx-trigger="editContent">
{{- credential_data.friendly_name or 'John Doe' -}} {{- credential.friendly_name or 'John Doe' -}}
</span> </span>
<a href="#" hx-disinherit="*" class="{{ "color-red" if not credential_data.active else "color-green"}}" <a href="#" hx-disinherit="*" class="{{ "color-red" if not credential.active else "color-green"}}"
hx-patch="/system/users/{{ user.id }}/credential/{{ hex_id }}" hx-patch="/system/users/{{ user.id }}/credential/{{ credential.id|hex }}"
hx-vals='js:{"active": {{ "true" if not credential_data.active else "false"}}}' hx-vals='js:{"active": {{ "true" if not credential.active else "false"}}}'
hx-params="active"> hx-params="active">
{{ "[disabled]" if not credential_data.active else "[enabled]"}} {{ "[disabled]" if not credential.active else "[enabled]"}}
</a>
<a href="#" hx-disinherit="*" class="color-red"
hx-confirm="Delete passkey?"
_="install confirmButton"
hx-trigger="confirmedButton throttle:200ms"
hx-delete="/system/users/{{ user.id }}/credential/{{ credential.id|hex }}">
[remove]
</a> </a>
<br> <br>
<small>Last Login: <small>Last Login:
{% if credential_data.last_login %} {% if credential.last_login %}
<span class="" value="{{ credential_data.last_login }}" _="init set dt to my @value js(dt) return new Date(dt).toLocaleString() end then put result into me"></span> <span class="" value="{{ credential.last_login }}" _="init set dt to my @value js(dt) return new Date(dt).toLocaleString() end then put result into me"></span>
{% else %}- {% else %}-
{% endif %} {% endif %}
</small> </small>
@ -74,8 +75,7 @@
</article> </article>
<article> <article>
<h5>Profile data</h5> <h6>Profile data</h6>
{% with {% with
schema=schemas.user_profile, schema=schemas.user_profile,
current_data=user.profile, current_data=user.profile,

View File

@ -5,7 +5,7 @@
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"> <nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
<ul> <ul>
<li>System</li> <li><span>System</span></li>
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Users</a></li> <li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Users</a></li>
</ul> </ul>
</nav> </nav>
@ -13,7 +13,7 @@
{% block body %} {% block body %}
<h4>Users</h4> <h5>Users</h5>
<details class="show-below-lg"> <details class="show-below-lg">
<summary role="button" class="button-slate-800">Pending logins and registrations</summary> <summary role="button" class="button-slate-800">Pending logins and registrations</summary>