Cat.
Signed-off-by: apeters <apeters@korves.net>
This commit is contained in:
parent
3f3a7ba2d5
commit
519dbc73c6
@ -1,18 +1,25 @@
|
||||
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.logs import logger
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
for bust_uuid in bust_uuids:
|
||||
bust_uuid = str(bust_uuid)
|
||||
|
||||
for user_id in IN_MEMORY_DB["CACHE"]["MODELS"]:
|
||||
cached_keys = list(IN_MEMORY_DB["CACHE"]["MODELS"][user_id].keys())
|
||||
if bust_uuid in cached_keys:
|
||||
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]
|
||||
|
||||
for user_id in IN_MEMORY_DB["CACHE"]["FORMS"]:
|
||||
|
||||
@ -137,13 +137,6 @@ class Cluster:
|
||||
db_params = evaluate_db_params(ticket)
|
||||
|
||||
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:
|
||||
self._session_patched_tables[ticket] = set()
|
||||
|
||||
@ -176,9 +169,7 @@ class Cluster:
|
||||
ticket=ticket,
|
||||
)
|
||||
break
|
||||
db.table(table).upsert(
|
||||
Document(b, doc_id=doc_id)
|
||||
)
|
||||
db.table(table).upsert(Document(b, doc_id=doc_id))
|
||||
else: # if no break occured, continue
|
||||
for doc_id, doc in diff["added"].items():
|
||||
db.table(table).insert(
|
||||
@ -199,9 +190,7 @@ class Cluster:
|
||||
)
|
||||
db.table(table).truncate()
|
||||
for doc_id, doc in insert_data.items():
|
||||
db.table(table).insert(
|
||||
Document(doc, doc_id=doc_id)
|
||||
)
|
||||
db.table(table).insert(Document(doc, doc_id=doc_id))
|
||||
|
||||
await self.send_command(
|
||||
"ACK", peer_meta["name"], ticket=ticket
|
||||
|
||||
@ -27,10 +27,36 @@ TINYDB_PARAMS = {
|
||||
"indent": 2,
|
||||
"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["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)
|
||||
|
||||
|
||||
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):
|
||||
db_params = copy(TINYDB_PARAMS)
|
||||
transaction_file = (
|
||||
|
||||
@ -24,7 +24,6 @@ class ConnectionStatus(Enum):
|
||||
|
||||
class CritErrors(Enum):
|
||||
NOT_READY = "CRIT:NOT_READY"
|
||||
NO_SUCH_TABLE = "CRIT:NO_SUCH_TABLE"
|
||||
TABLE_HASH_MISMATCH = "CRIT:TABLE_HASH_MISMATCH"
|
||||
CANNOT_APPLY = "CRIT:CANNOT_APPLY"
|
||||
NOTHING_TO_COMMIT = "CRIT:NOTHING_TO_COMMIT"
|
||||
|
||||
@ -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):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
@ -102,6 +66,8 @@ class UserProfile(BaseModel):
|
||||
k in tresor.keys()
|
||||
for k in ["public_key_pem", "wrapped_private_key", "iv", "salt"]
|
||||
)
|
||||
else:
|
||||
v = None
|
||||
return v
|
||||
except:
|
||||
raise PydanticCustomError(
|
||||
@ -113,7 +79,7 @@ class UserProfile(BaseModel):
|
||||
tresor: str | None = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"title": "Personal tresor",
|
||||
"title": "🔐 Personal tresor",
|
||||
"type": "tresor",
|
||||
"input_extra": 'autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"',
|
||||
"form_id": f"tresor-{str(uuid4())}",
|
||||
@ -163,15 +129,52 @@ class UserProfile(BaseModel):
|
||||
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):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
id: Annotated[str, AfterValidator(lambda v: str(UUID(v)))]
|
||||
login: constr(strip_whitespace=True, min_length=1)
|
||||
credentials: dict[str, Credential] | Annotated[
|
||||
str | list[str],
|
||||
AfterValidator(lambda v: ensure_list(v)),
|
||||
] = {}
|
||||
credentials: list[Credential | CredentialAdd] = []
|
||||
acl: Annotated[
|
||||
Literal[*USER_ACLS] | list[Literal[*USER_ACLS]],
|
||||
AfterValidator(lambda v: ensure_list(v)),
|
||||
@ -221,7 +224,6 @@ class UserAdd(BaseModel):
|
||||
class UserPatch(BaseModel):
|
||||
login: str | None = None
|
||||
acl: str | list = []
|
||||
credentials: str | list = []
|
||||
groups: str | list | None = None
|
||||
|
||||
@computed_field
|
||||
|
||||
@ -9,6 +9,7 @@ from components.web.utils.quart import current_app, session
|
||||
from components.utils import ensure_list, merge_models
|
||||
from components.cache import buster
|
||||
from components.database import *
|
||||
from components.database import ANONYMOUS_CACHE_ID
|
||||
|
||||
|
||||
@validate_call
|
||||
@ -20,10 +21,7 @@ async def get(
|
||||
get_objects = ObjectIdList(object_id=object_id).object_id
|
||||
db_params = evaluate_db_params()
|
||||
|
||||
if current_app and session.get("id"):
|
||||
user_id = session["id"]
|
||||
else:
|
||||
user_id = "99999999-9999-9999-9999-999999999999"
|
||||
user_id = session["id"] if current_app and session.get("id") else ANONYMOUS_CACHE_ID
|
||||
|
||||
if not user_id in IN_MEMORY_DB["CACHE"]["MODELS"]:
|
||||
IN_MEMORY_DB["CACHE"]["MODELS"][user_id] = dict()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from components.models.users import (
|
||||
AddCredential,
|
||||
CredentialAdd,
|
||||
CredentialPatch,
|
||||
Credential,
|
||||
User,
|
||||
@ -14,16 +14,10 @@ from components.models.users import (
|
||||
)
|
||||
from components.utils import merge_models
|
||||
from components.database import *
|
||||
from components.database import SYSTEM_CACHE_ID
|
||||
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
|
||||
async def what_id(login: str):
|
||||
db_params = evaluate_db_params()
|
||||
@ -48,38 +42,20 @@ async def create(data: dict):
|
||||
insert_data = create_user.model_dump(mode="json")
|
||||
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"]
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get(user_id: UUID, join_credentials: bool = True):
|
||||
async def get(user_id: UUID):
|
||||
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:
|
||||
if not str(user_id) in IN_MEMORY_DB["CACHE"]["MODELS"][system_id]:
|
||||
print(
|
||||
User.model_validate(db.table("users").get(Query().id == str(user_id)))
|
||||
)
|
||||
IN_MEMORY_DB["CACHE"]["MODELS"][system_id][
|
||||
if not str(user_id) in IN_MEMORY_DB["CACHE"]["MODELS"][SYSTEM_CACHE_ID]:
|
||||
IN_MEMORY_DB["CACHE"]["MODELS"][SYSTEM_CACHE_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()
|
||||
|
||||
credentials = db.table("credentials").search(
|
||||
(Query().id.one_of(user.credentials))
|
||||
)
|
||||
|
||||
if join_credentials:
|
||||
user.credentials = _create_credentials_mapping(credentials)
|
||||
user = IN_MEMORY_DB["CACHE"]["MODELS"][SYSTEM_CACHE_ID][str(user_id)].copy()
|
||||
|
||||
return user
|
||||
|
||||
@ -87,7 +63,7 @@ async def get(user_id: UUID, join_credentials: bool = True):
|
||||
@validate_call
|
||||
async def delete(user_id: UUID):
|
||||
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:
|
||||
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:
|
||||
raise ValueError("name", "Cannot delete last user")
|
||||
|
||||
db.table("credentials").remove(Query().id.one_of(user.credentials))
|
||||
deleted = db.table("users").remove(Query().id == str(user_id))
|
||||
db.table("users").remove(Query().id == str(user_id))
|
||||
buster(user.id)
|
||||
return user.id
|
||||
|
||||
@ -105,18 +80,18 @@ async def delete(user_id: UUID):
|
||||
@validate_call
|
||||
async def create_credential(user_id: UUID, data: dict):
|
||||
db_params = evaluate_db_params()
|
||||
credential = AddCredential.model_validate(data)
|
||||
user = await get(user_id=user_id, join_credentials=False)
|
||||
credential = CredentialAdd.model_validate(data)
|
||||
user = await get(user_id=user_id)
|
||||
if not user:
|
||||
raise ValueError("name", "The provided user does not exist")
|
||||
|
||||
async with TinyDB(**db_params) as db:
|
||||
db.table("credentials").insert(credential.model_dump(mode="json"))
|
||||
user.credentials.append(credential.id)
|
||||
user.credentials.append(credential)
|
||||
db.table("users").update(
|
||||
{"credentials": user.credentials},
|
||||
{"credentials": user.model_dump(mode="json")["credentials"]},
|
||||
Query().id == str(user_id),
|
||||
)
|
||||
buster(user.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)
|
||||
):
|
||||
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:
|
||||
raise ValueError("name", "The provided user does not exist")
|
||||
|
||||
async with TinyDB(**db_params) as db:
|
||||
if hex_id in user.credentials:
|
||||
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)
|
||||
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(
|
||||
"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
|
||||
|
||||
|
||||
@validate_call
|
||||
async def patch(user_id: UUID, data: dict):
|
||||
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:
|
||||
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")
|
||||
|
||||
orphaned_credentials = [
|
||||
c for c in user.credentials if c not in patched_user.credentials
|
||||
]
|
||||
db.table("users").update(
|
||||
patched_user.model_dump(mode="json"),
|
||||
Query().id == str(user_id),
|
||||
)
|
||||
db.table("credentials").remove(Query().id.one_of(orphaned_credentials))
|
||||
|
||||
buster(user.id)
|
||||
return user.id
|
||||
|
||||
@ -177,7 +157,7 @@ async def patch(user_id: UUID, data: dict):
|
||||
@validate_call
|
||||
async def patch_profile(user_id: UUID, data: dict):
|
||||
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:
|
||||
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")},
|
||||
Query().id == str(user_id),
|
||||
)
|
||||
buster(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
|
||||
):
|
||||
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:
|
||||
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(
|
||||
"hex_id",
|
||||
"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(
|
||||
user.credentials[hex_id],
|
||||
patch_data,
|
||||
matched_user_credential,
|
||||
CredentialPatch.model_validate(data),
|
||||
exclude_strategies=["exclude_override_none"],
|
||||
)
|
||||
|
||||
user.credentials.append(patched_credential)
|
||||
|
||||
async with TinyDB(**db_params) as db:
|
||||
db.table("credentials").update(
|
||||
patched_credential.model_dump(mode="json"), Query().id == hex_id
|
||||
db.table("users").update(
|
||||
{"credentials": user.model_dump(mode="json")["credentials"]},
|
||||
Query().id == str(user_id),
|
||||
)
|
||||
buster(user_id)
|
||||
return hex_id
|
||||
|
||||
|
||||
@validate_call
|
||||
async def search(
|
||||
name: constr(strip_whitespace=True, min_length=0), join_credentials: bool = True
|
||||
):
|
||||
async def search(name: constr(strip_whitespace=True, min_length=0)):
|
||||
db_params = evaluate_db_params()
|
||||
|
||||
def search_name(s):
|
||||
@ -237,6 +225,4 @@ async def search(
|
||||
async with TinyDB(**db_params) as db:
|
||||
matches = db.table("users").search(Query().login.test(search_name))
|
||||
|
||||
return [
|
||||
await get(user["id"], join_credentials=join_credentials) for user in matches
|
||||
]
|
||||
return [await get(user["id"]) for user in matches]
|
||||
|
||||
@ -30,18 +30,6 @@ app.config["SECRET_KEY"] = defaults.SECRET_KEY
|
||||
app.config["TEMPLATES_AUTO_RELOAD"] = defaults.TEMPLATES_AUTO_RELOAD
|
||||
app.config["SERVER_NAME"] = defaults.HOSTNAME
|
||||
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"])
|
||||
|
||||
|
||||
@ -299,8 +299,7 @@ async def login_webauthn_options():
|
||||
return validation_error([{"loc": ["login"], "msg": f"User is not available"}])
|
||||
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=bytes.fromhex(c))
|
||||
for c in user.credentials.keys()
|
||||
PublicKeyCredentialDescriptor(id=c.id) for c in user.credentials
|
||||
]
|
||||
|
||||
options = generate_authentication_options(
|
||||
@ -402,8 +401,7 @@ async def register_webauthn_options():
|
||||
user = await get_user(user_id=session["id"])
|
||||
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=bytes.fromhex(c))
|
||||
for c in user.credentials.keys()
|
||||
PublicKeyCredentialDescriptor(id=c.id) for c in user.credentials
|
||||
]
|
||||
|
||||
user_id = session["id"]
|
||||
@ -491,10 +489,9 @@ async def register_webauthn():
|
||||
}
|
||||
|
||||
try:
|
||||
async with ClusterLock(["users", "credentials"], current_app):
|
||||
async with ClusterLock("users", current_app):
|
||||
if not appending_passkey:
|
||||
user_id = await create_user(data={"login": login})
|
||||
|
||||
await create_credential(
|
||||
user_id=user_id,
|
||||
data={
|
||||
@ -506,7 +503,7 @@ async def register_webauthn():
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.critical(e)
|
||||
return trigger_notification(
|
||||
level="error",
|
||||
response_code=409,
|
||||
@ -516,16 +513,12 @@ async def register_webauthn():
|
||||
)
|
||||
|
||||
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(
|
||||
level="success",
|
||||
response_code=204,
|
||||
title="New token registered",
|
||||
message="A new token was appended to your account and can now be used to login",
|
||||
additional_triggers={"appendCompleted": ""},
|
||||
)
|
||||
|
||||
return trigger_notification(
|
||||
@ -566,10 +559,9 @@ async def auth_login_verify():
|
||||
|
||||
credential = parse_authentication_credential_json(json_body)
|
||||
|
||||
matched_user_credential = None
|
||||
for k, v in user.credentials.items():
|
||||
if bytes.fromhex(k) == credential.raw_id:
|
||||
matched_user_credential = v
|
||||
matched_user_credential = next(
|
||||
(c for c in user.credentials if c.id == credential.raw_id), None
|
||||
)
|
||||
|
||||
if not matched_user_credential:
|
||||
return trigger_notification(
|
||||
@ -594,7 +586,7 @@ async def auth_login_verify():
|
||||
if matched_user_credential.sign_count != 0:
|
||||
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)
|
||||
await patch_credential(
|
||||
user_id=user_id,
|
||||
@ -603,7 +595,7 @@ async def auth_login_verify():
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.critical(e)
|
||||
return trigger_notification(
|
||||
level="error",
|
||||
response_code=409,
|
||||
|
||||
@ -23,31 +23,27 @@ async def user_group():
|
||||
|
||||
assigned_to = [
|
||||
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
|
||||
]
|
||||
|
||||
assign_to = []
|
||||
for user_id in request_data.members:
|
||||
assign_to.append(
|
||||
await components.users.get(user_id=user_id, join_credentials=False)
|
||||
)
|
||||
assign_to.append(await components.users.get(user_id=user_id))
|
||||
|
||||
_all = assigned_to + assign_to
|
||||
|
||||
async with ClusterLock(["users", "credentials"], current_app):
|
||||
async with ClusterLock("users", 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.name in user.groups:
|
||||
user.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)
|
||||
if request_data.new_name not in user.groups and user in assign_to:
|
||||
user.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
|
||||
|
||||
|
||||
@ -8,8 +8,7 @@ blueprint = Blueprint("profile", __name__, url_prefix="/profile")
|
||||
|
||||
@blueprint.context_processor
|
||||
def load_context():
|
||||
context = dict()
|
||||
context["schemas"] = {"user_profile": UserProfile.model_json_schema()}
|
||||
context = {"schemas": {"user_profile": UserProfile.model_json_schema()}}
|
||||
return context
|
||||
|
||||
|
||||
@ -25,14 +24,7 @@ async def user_profile_get():
|
||||
name, message = e.args
|
||||
return validation_error([{"loc": [name], "msg": message}])
|
||||
|
||||
return await render_template(
|
||||
"profile/profile.html",
|
||||
data={
|
||||
"user": user.dict(),
|
||||
"keypair": None,
|
||||
"credentials": user.credentials,
|
||||
},
|
||||
)
|
||||
return await render_template("profile/profile.html", user=user)
|
||||
|
||||
|
||||
@blueprint.route("/edit", methods=["PATCH"])
|
||||
@ -68,7 +60,7 @@ async def user_profile_patch():
|
||||
@acl("any")
|
||||
async def patch_credential(credential_hex_id: str):
|
||||
try:
|
||||
async with ClusterLock("credentials", current_app):
|
||||
async with ClusterLock("users", current_app):
|
||||
await components.users.patch_credential(
|
||||
user_id=session["id"],
|
||||
hex_id=credential_hex_id,
|
||||
@ -89,7 +81,7 @@ async def patch_credential(credential_hex_id: str):
|
||||
@acl("any")
|
||||
async def delete_credential(credential_hex_id: str):
|
||||
try:
|
||||
async with ClusterLock(["credentials", "users"], current_app):
|
||||
async with ClusterLock("users", current_app):
|
||||
await components.users.delete_credential(
|
||||
user_id=session["id"], hex_id=credential_hex_id
|
||||
)
|
||||
|
||||
@ -33,7 +33,7 @@ async def logout():
|
||||
async def ws():
|
||||
while True:
|
||||
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()
|
||||
try:
|
||||
|
||||
@ -11,8 +11,7 @@ blueprint = Blueprint("users", __name__, url_prefix="/system/users")
|
||||
def load_context():
|
||||
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
|
||||
|
||||
|
||||
@ -28,7 +27,7 @@ async def get_user(user_id: str):
|
||||
return validation_error([{"loc": [name], "msg": message}])
|
||||
|
||||
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())
|
||||
|
||||
if request.method == "POST":
|
||||
matched_users = [
|
||||
m.dict() for m in await components.users.search(name=search_model.q)
|
||||
]
|
||||
matched_users = [m for m in await components.users.search(name=search_model.q)]
|
||||
|
||||
user_pages = [
|
||||
m
|
||||
for m in batch(
|
||||
sorted(
|
||||
matched_users,
|
||||
key=lambda x: x.get(sort_attr, "id"),
|
||||
key=lambda x: getattr(x, sort_attr, "id"),
|
||||
reverse=sort_reverse,
|
||||
),
|
||||
page_size,
|
||||
@ -97,7 +94,7 @@ async def delete_user(user_id: str | None = None):
|
||||
user_ids = request.form_parsed.get("id")
|
||||
|
||||
try:
|
||||
async with ClusterLock(["users", "credentials"], current_app):
|
||||
async with ClusterLock("users", current_app):
|
||||
for user_id in ensure_list(user_ids):
|
||||
await components.users.delete(user_id=user_id)
|
||||
|
||||
@ -119,7 +116,7 @@ async def delete_user(user_id: str | None = None):
|
||||
@acl("system")
|
||||
async def patch_user_credential(user_id: str, hex_id: str):
|
||||
try:
|
||||
async with ClusterLock("credentials", current_app):
|
||||
async with ClusterLock("users", current_app):
|
||||
await components.users.patch_credential(
|
||||
user_id=user_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("/<user_id>", methods=["PATCH"])
|
||||
@acl("system")
|
||||
@ -147,7 +164,7 @@ async def patch_user(user_id: str | None = None):
|
||||
if not user_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_profile(
|
||||
user_id=user_id, data=request.form_parsed.get("profile", {})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -119,16 +119,19 @@ pre {
|
||||
padding: calc(var(--pico-spacing)/2);
|
||||
}
|
||||
|
||||
#nav-theme-toggle {
|
||||
cursor:pointer !important;
|
||||
nav[aria-label="breadcrumb"] span {
|
||||
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 {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
.hi, .hi a {
|
||||
font-size:1.1rem;
|
||||
--pico-text-decoration: none;
|
||||
}
|
||||
|
||||
table td article {
|
||||
margin-bottom: var(--pico-spacing);
|
||||
@ -145,6 +148,10 @@ table td.created-modified {
|
||||
table td.created-modified, table th.created-modified {
|
||||
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 {
|
||||
text-decoration: none !important;
|
||||
@ -155,10 +162,6 @@ table td.created-modified, table th.created-modified {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.help {
|
||||
cursor:help;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor:pointer;
|
||||
}
|
||||
@ -265,15 +268,11 @@ table td.created-modified, table th.created-modified {
|
||||
--pico-color: #{$fuchsia-100};
|
||||
}
|
||||
|
||||
.login-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 20% 60% 20%;
|
||||
grid-template-rows: 1fr;
|
||||
article.login-mask {
|
||||
padding: calc(var(--pico-spacing)*2);
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
.login-register { grid-column-start: 2; }
|
||||
|
||||
|
||||
thead th, thead td, tfoot th, tfoot td {
|
||||
--pico-font-weight: 400;
|
||||
}
|
||||
@ -323,7 +322,6 @@ dialog article {
|
||||
align-items: baseline;
|
||||
margin: calc(var(--pico-spacing) /2) auto;
|
||||
}
|
||||
|
||||
.grid-auto-cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
@ -350,10 +348,6 @@ nav details.dropdown {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
fieldset.vault-unlock {
|
||||
padding: var(--pico-spacing) 0;
|
||||
}
|
||||
|
||||
article.user-group {
|
||||
background-color: var(--pico-form-element-background-color);
|
||||
}
|
||||
@ -429,6 +423,11 @@ fieldset.keypair, fieldset.tresor {
|
||||
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
|
||||
@ -468,31 +467,43 @@ fieldset.keypair, fieldset.tresor {
|
||||
|
||||
@each $color-key, $color-var in $colors {
|
||||
@each $shade, $value in $color-var {
|
||||
.color-#{"#{$color-key}"}-#{$shade} {
|
||||
.color-#{$color-key}-#{$shade} {
|
||||
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);
|
||||
border-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;
|
||||
}
|
||||
}
|
||||
|
||||
@if map-has-key($color-var, 500) {
|
||||
.color-#{"#{$color-key}"} {
|
||||
@extend .color-#{"#{$color-key}"}-500;
|
||||
.color-#{$color-key} {
|
||||
@extend .color-#{$color-key}-500;
|
||||
}
|
||||
:is(button, [type="submit"], [type="button"], [role="button"]).button-#{"#{$color-key}"},
|
||||
[type="reset"].button-#{"#{$color-key}"} {
|
||||
@extend .button-#{"#{$color-key}"}-500;
|
||||
|
||||
:is(button, [type="submit"], [type="button"], [role="button"]).button-#{$color-key},
|
||||
[type="reset"].button-#{$color-key} {
|
||||
@extend .button-#{$color-key}-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@each $size, $data in $breakpoints {
|
||||
$breakpoint: map-get($data, breakpoint);
|
||||
@media (max-width: $breakpoint) {
|
||||
|
||||
@ -150,28 +150,37 @@ end
|
||||
|
||||
behavior tresorToggle
|
||||
def setUnlocked
|
||||
get #vault-unlock-pin
|
||||
get #menu-dialog-vault-unlock-pin
|
||||
add @disabled to it
|
||||
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
|
||||
def setLocked
|
||||
get #vault-unlock-pin
|
||||
get #menu-dialog-vault-unlock-pin
|
||||
remove @disabled from it
|
||||
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
|
||||
def noTresor
|
||||
get #vault-unlock-pin
|
||||
get #menu-dialog-vault-unlock-pin
|
||||
add @disabled to it
|
||||
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
|
||||
init
|
||||
if window.vault.isUnlocked()
|
||||
call setUnlocked()
|
||||
else
|
||||
if #vault-unlock's @data-tresor != ""
|
||||
if #menu-dialog-vault-unlock's @data-tresor != ""
|
||||
call setLocked()
|
||||
else
|
||||
call noTresor()
|
||||
@ -179,32 +188,33 @@ behavior tresorToggle
|
||||
end
|
||||
end
|
||||
on profileUpdate from body
|
||||
exit unless #vault-unlock's @data-tresor == ""
|
||||
set #vault-unlock's @data-tresor to (value of event.detail)
|
||||
exit unless #menu-dialog-vault-unlock's @data-tresor == ""
|
||||
set #menu-dialog-vault-unlock's @data-tresor to (value of event.detail)
|
||||
call setLocked()
|
||||
end
|
||||
on keydown[keyCode == 13] from #vault-unlock-pin
|
||||
trigger click on #vault-unlock unless #vault-unlock-pin's value is empty
|
||||
on keydown[keyCode == 13] from #menu-dialog-vault-unlock-pin
|
||||
trigger click on #menu-dialog-vault-unlock unless #menu-dialog-vault-unlock-pin's value is empty
|
||||
end
|
||||
on click from #vault-unlock
|
||||
on click from #menu-dialog-vault-unlock
|
||||
halt the event
|
||||
if not window.vault.isUnlocked()
|
||||
exit unless value of #vault-unlock-pin
|
||||
call JSON.parse(#vault-unlock's @data-tresor) set keyData to the result
|
||||
call VaultUnlockPrivateKey(value of #vault-unlock-pin, keyData)
|
||||
throw "No PIN" unless value of #menu-dialog-vault-unlock-pin
|
||||
call JSON.parse(#menu-dialog-vault-unlock's @data-tresor) set keyData to the result
|
||||
call VaultUnlockPrivateKey(value of #menu-dialog-vault-unlock-pin, keyData)
|
||||
call setUnlocked()
|
||||
else
|
||||
call window.vault.lock()
|
||||
call setLocked()
|
||||
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)
|
||||
trigger notification(
|
||||
title: 'Tresor error',
|
||||
level: 'validationError',
|
||||
message: 'Could not unlock tresor, check your PIN',
|
||||
duration: 3000,
|
||||
locations: ['vault-unlock-pin']
|
||||
locations: ['menu-vault-unlock-pin']
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -213,7 +223,23 @@ behavior bodydefault
|
||||
on htmx:wsError or htmx:wsClose
|
||||
set #ws-indicator's textContent to '⭕'
|
||||
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
|
||||
exit unless window.vault.isUnlocked()
|
||||
if navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
@ -222,16 +248,12 @@ behavior bodydefault
|
||||
set ctrlOrCmd to event.ctrlKey
|
||||
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"))
|
||||
trigger notification(
|
||||
title: 'Unlocked session',
|
||||
level: 'user',
|
||||
message: 'Preventing window reload due to unlocked session',
|
||||
duration: 2000,
|
||||
locations: []
|
||||
)
|
||||
trigger forceReload
|
||||
if :reloadCounter < 2
|
||||
halt the event
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
on htmx:responseError
|
||||
set status to event.detail.xhr.status
|
||||
|
||||
@ -26,6 +26,10 @@ htmx.on("body", "regCompleted", async function(evt){
|
||||
htmx.ajax("GET", "/", "#body-main")
|
||||
})
|
||||
|
||||
htmx.on("body", "appendCompleted", async function(evt){
|
||||
htmx.ajax("GET", "/", "#body-main")
|
||||
})
|
||||
|
||||
htmx.on("body", "startAuth", async function(evt){
|
||||
const { startAuthentication } = SimpleWebAuthnBrowser
|
||||
var login_button = htmx.find("#authenticate")
|
||||
|
||||
@ -97,6 +97,14 @@ class UserCryptoVault {
|
||||
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) {
|
||||
if (!this.keyPair?.publicKey || !this.keyPair?.privateKey) throw new Error("Vault not unlocked");
|
||||
|
||||
|
||||
BIN
components/web/static_files/logo.png
Normal file
BIN
components/web/static_files/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
components/web/static_files/logo2.png
Normal file
BIN
components/web/static_files/logo2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@ -5,17 +5,17 @@
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||
<ul>
|
||||
<li>Welcome 👋</li>
|
||||
<li>Login / Register</li>
|
||||
<li><span>Welcome 👋</span></li>
|
||||
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Login or register</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
{% block body %}
|
||||
<div class="login-grid">
|
||||
<article class="login-mask">
|
||||
{% include "auth/includes/login/login.html" %}
|
||||
{% with hidden=True %}
|
||||
{% include "auth/includes/register/register.html" %}
|
||||
{% endwith %}
|
||||
</section>
|
||||
</article>
|
||||
{% endblock body %}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"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
|
||||
data-loading-disable
|
||||
hx-trigger="submit throttle:1s"
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
"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
|
||||
hx-trigger="submit throttle:1s"
|
||||
hx-post="/auth/register/token"
|
||||
class="login-register" id="register-form" {{ 'hidden' if hidden }}>
|
||||
hx-post="/auth/register/token">
|
||||
<fieldset>
|
||||
<label for="webauthn-register">Pick a username</label>
|
||||
<input type="text" id="webauthn-register" name="login"
|
||||
autocomplete="off"
|
||||
@ -14,10 +15,12 @@
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required>
|
||||
</fieldset>
|
||||
<button type="submit" id="register">Next</button>
|
||||
<hr>
|
||||
<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>
|
||||
</p>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -3,17 +3,15 @@
|
||||
hx-trigger="submit throttle:1s"
|
||||
hx-post="/auth/register/webauthn/options">
|
||||
<input type="hidden" name="token" value="{{ token }}">
|
||||
<article>
|
||||
<header>
|
||||
<mark>{{ token }}</mark>
|
||||
</header>
|
||||
<div>
|
||||
<h5>Your token: <mark>{{ token }}</mark></h5>
|
||||
<p>
|
||||
A token intention was saved. Please ask an administrator to verify your token.<br>
|
||||
After validation you will be asked to register a passkey.
|
||||
</p>
|
||||
<footer>
|
||||
<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"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="off"
|
||||
@ -25,9 +23,9 @@
|
||||
hx-target="this"
|
||||
id="register"
|
||||
hx-swap="innerHTML">
|
||||
Validate <small>and register</small>
|
||||
Register
|
||||
</button>
|
||||
</fieldset>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
{% block menu %}
|
||||
{% include "includes/menu.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<hr>
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true"></nav>
|
||||
{% endblock %}
|
||||
|
||||
@ -59,13 +59,16 @@ DATA FIELDS
|
||||
</fieldset>
|
||||
|
||||
{% elif v.type == "tresor" %}
|
||||
|
||||
{% 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">
|
||||
<label>{{ v.title }}</label>
|
||||
<input id="{{ v.form_id }}" type="hidden" {{ v.input_extra|safe }}
|
||||
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] %}
|
||||
<legend>Setup tresor</legend>
|
||||
<input form="_ignore" id="{{ v.form_id }}-password" name="{{ v.form_id }}-password" type="password" {{ v.input_extra|safe }} />
|
||||
<small>Password</small>
|
||||
<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
|
||||
</a>
|
||||
{% 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 }} />
|
||||
<small>Current password</small>
|
||||
<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>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% elif v.type == "datalist" %}
|
||||
@ -243,7 +248,7 @@ DATA FIELDS
|
||||
<p>No key pairs available</p>
|
||||
{% else %}
|
||||
<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 }}
|
||||
name="{% if root_key %}{{ root_key }}.{{ k }}{% else %}{{ k }}{% endif %}"
|
||||
id="{{ v.form_id }}"
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
<nav hx-target="#body-main">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#" hx-get="/" class="nav-logo">
|
||||
{% include "ehlo.svg" %}
|
||||
<a href="#" hx-get="/">
|
||||
{% include "logo.svg" %}
|
||||
</a>
|
||||
<span aria-busy="true" data-loading></span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul>
|
||||
{% if not session["login"] %}
|
||||
<li>
|
||||
@ -90,34 +88,59 @@
|
||||
|
||||
|
||||
{% if session["login"] %}
|
||||
<div id="nav-sub-primary" hx-target="#body-main" class="grid-space-between">
|
||||
<div class="no-text-wrap hi">
|
||||
👋 <b><a href="#" hx-get="/profile/">{{ session.get("login") or "guest" }}</a></b>
|
||||
</div>
|
||||
<div hx-target="#body-main" class="grid-space-between">
|
||||
<a href="#" class="no-text-decoration"
|
||||
id="menu-vault-dialog-toggle"
|
||||
_="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>
|
||||
{% for role in session.get("acl", []) %}
|
||||
<mark>{{ role }}</mark>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
|
||||
<div id="nav-sub-secondary" 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>
|
||||
{% if "system" in session.get("acl", []) %}
|
||||
<div id="enforce-dbupdate" hx-swap-oob="outerHTML">
|
||||
{% if ENFORCE_DBUPDATE %}
|
||||
<button data-tooltip="Enforced database updates are enabled"
|
||||
class="button-red-800"
|
||||
id="enforce-dbupdate-button"
|
||||
hx-get="/system/status"
|
||||
_="on load call countdownSeconds(me, {{ ENFORCE_DBUPDATE }}) end">
|
||||
!!!
|
||||
</button>
|
||||
<div hx-target="#body-main" class="grid-end">
|
||||
<span aria-busy="true" data-loading></span>
|
||||
<span id="ws-indicator"
|
||||
{% if session["login"] %}
|
||||
class="no-text-decoration"
|
||||
data-tooltip="Disconnected"
|
||||
{% else %}
|
||||
class="no-text-decoration dark"
|
||||
data-tooltip="Not logged in"
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="nav-theme-toggle"
|
||||
hx-swap-oob="outerHTML">⭕</span>
|
||||
<span id="nav-theme-bulb"
|
||||
_="on updateTheme
|
||||
if not localStorage.theme
|
||||
if window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
@ -133,6 +156,7 @@
|
||||
end
|
||||
init trigger updateTheme end
|
||||
on click
|
||||
halt the event
|
||||
if I match .light
|
||||
set (@data-theme of <html/>) to 'dark'
|
||||
else
|
||||
@ -141,26 +165,23 @@
|
||||
set localStorage.theme to (@data-theme of <html/>)
|
||||
toggle between .light and .dark
|
||||
end
|
||||
">💡 Theme
|
||||
</div>
|
||||
">💡
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if session["login"] %}
|
||||
<section>
|
||||
<hr>
|
||||
<fieldset _="install tresorToggle" role="group">
|
||||
<input hx-disable
|
||||
id="vault-unlock-pin"
|
||||
name="vault-unlock-pin"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
data-protonpass-ignore="true"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"/>
|
||||
<a role="button" href="#" id="vault-unlock" data-tresor="{{ session.profile.tresor }}">...</a>
|
||||
</fieldset>
|
||||
</section>
|
||||
{% if "system" in session.get("acl", []) %}
|
||||
<div hx-target="#body-main" id="enforce-dbupdate" hx-swap-oob="outerHTML">
|
||||
{% if ENFORCE_DBUPDATE %}
|
||||
<button data-tooltip="Enforced database updates are enabled"
|
||||
class="button-red-800"
|
||||
id="enforce-dbupdate-button"
|
||||
hx-get="/system/status"
|
||||
_="on load call countdownSeconds(me, {{ ENFORCE_DBUPDATE }}) end">
|
||||
!!!
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
483
components/web/templates/logo.svg
Normal file
483
components/web/templates/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 68 KiB |
@ -5,7 +5,7 @@
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||
<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") }}/{{ object.id }}">{{ object.name }}</a></li>
|
||||
</ul>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||
<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>
|
||||
</ul>
|
||||
</nav>
|
||||
@ -13,13 +13,13 @@
|
||||
|
||||
{% 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">
|
||||
<summary role="button" class="button-slate-800">Create object</summary>
|
||||
<article>
|
||||
<hgroup>
|
||||
<h5>New object</h5>
|
||||
<h6>New object</h6>
|
||||
<p>Create an object by defining a unique name.</p>
|
||||
</hgroup>
|
||||
{% include "objects/includes/create/" ~ request.view_args.get("object_type") ~ ".html" %}
|
||||
@ -29,7 +29,7 @@
|
||||
<div class="grid split-grid">
|
||||
<article class="hide-below-lg">
|
||||
<hgroup>
|
||||
<h5>New object</h5>
|
||||
<h6>New object</h6>
|
||||
<p>Create an object by defining a unique name.</p>
|
||||
</hgroup>
|
||||
{% include "objects/includes/create/" ~ request.view_args.get("object_type") ~ ".html" %}
|
||||
|
||||
@ -1,68 +1,48 @@
|
||||
<article id="profile-authenticators">
|
||||
<h5>Passkeys</h5>
|
||||
<p>The authenticator that started the session is indicated as active.</p>
|
||||
<div class="overflow-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Last login</th>
|
||||
<th scope="col">Action</th>
|
||||
<th scope="col" class="created-modified">Created / Updated</th>
|
||||
</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 }}"
|
||||
|
||||
<h6>Credentials</h6>
|
||||
{% if not user.credentials %}
|
||||
<i>No credentials available</i>
|
||||
{% endif %}
|
||||
{% for credential in user.credentials %}
|
||||
<fieldset id="profile-credential-{{ credential.id|hex }}"
|
||||
hx-trigger="htmx:afterRequest[event.detail.successful==true] from:#profile-credential-{{ credential.id|hex }}"
|
||||
hx-target="this"
|
||||
hx-select="#profile-credential-{{ hex_id }}"
|
||||
hx-select="#profile-credential-{{ credential.id|hex }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-get="/profile/">
|
||||
<th scope="row">
|
||||
{% if session["cred_id"] == hex_id %}
|
||||
{% if session["cred_id"] == credential.id|hex %}
|
||||
<mark>in use</mark>
|
||||
{% endif %}
|
||||
<span _="install inlineHtmxRename()"
|
||||
contenteditable
|
||||
data-patch-parameter="friendly_name"
|
||||
spellcheck="false"
|
||||
hx-patch="/profile/credential/{{ hex_id }}"
|
||||
hx-patch="/profile/credential/{{ credential.id|hex }}"
|
||||
hx-trigger="editContent">
|
||||
{{- credential_data.friendly_name or 'John Doe' -}}
|
||||
{{- credential.friendly_name or 'John Doe' -}}
|
||||
</span>
|
||||
</th>
|
||||
<td>
|
||||
{% if credential_data.last_login %}
|
||||
<span _="init js return new Date('{{ credential_data.last_login }}').toLocaleString() end then put result into me">{{ credential_data.last_login }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="#" role="button" class="button-red"
|
||||
hx-confirm="Delete token?"
|
||||
<a href="#" hx-disinherit="*" class="{{ "color-red" if not credential.active else "color-green"}}"
|
||||
hx-patch="/profile/credential/{{ credential.id|hex }}"
|
||||
hx-vals='js:{"active": {{ "true" if not credential.active else "false"}}}'
|
||||
hx-params="active">{{ "[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="/profile/credential/{{ hex_id }}">
|
||||
Remove
|
||||
</a>
|
||||
<a href="#" role="button" class="{{ "outline" if not credential_data.active else ""}}"
|
||||
hx-patch="/profile/credential/{{ hex_id }}"
|
||||
hx-vals='js:{"active": {{ "true" if not credential_data.active else "false"}}}'
|
||||
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>✏️ <small _="init js return new Date('{{- credential_data.updated -}}').toLocaleString() end then put result into me">{{- credential_data.updated -}}</small>
|
||||
hx-delete="/profile/credential/{{ credential.id|hex }}">[remove]</a>
|
||||
<br>
|
||||
<small>Last Login:
|
||||
{% if credential.last_login %}
|
||||
<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 %}-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</small>
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button type="submit" hx-post="/auth/register/webauthn/options"
|
||||
data-loading-disable
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||
<ul>
|
||||
<li>Profile</li>
|
||||
<li><span>Profile</span></li>
|
||||
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">{{ session["login"] }}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
@ -13,14 +13,14 @@
|
||||
|
||||
{% 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">
|
||||
<h5>Profile</h5>
|
||||
<h6>Profile</h6>
|
||||
<form hx-trigger="submit throttle:1s" hx-patch="/profile/edit" id="profile-form">
|
||||
{% with
|
||||
schema=schemas.user_profile,
|
||||
current_data=data.user.profile
|
||||
current_data=user.profile
|
||||
%}
|
||||
{% include "includes/form_builder.html" %}
|
||||
{% endwith %}
|
||||
|
||||
@ -10,6 +10,9 @@
|
||||
data-layer-role="icon"
|
||||
version="1.1"
|
||||
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="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
@ -24,14 +27,14 @@
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="11.313709"
|
||||
inkscape:cx="46.182912"
|
||||
inkscape:cy="33.45499"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1355"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg194" />
|
||||
inkscape:cx="23.378717"
|
||||
inkscape:cy="33.587571"
|
||||
inkscape:window-width="1280"
|
||||
inkscape:window-height="1312"
|
||||
inkscape:window-x="1280"
|
||||
inkscape:window-y="35"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="main" />
|
||||
<path
|
||||
class="st0"
|
||||
style="clip-rule:evenodd;fill:#5a915b;fill-rule:evenodd"
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@ -6,19 +6,19 @@
|
||||
<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>
|
||||
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Groups</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
<h4>Groups</h4>
|
||||
<h5>Groups</h5>
|
||||
|
||||
<div class="grid split-grid">
|
||||
<article class="hide-below-lg">
|
||||
<hgroup>
|
||||
<h5>Groups object</h5>
|
||||
<h6>Groups object</h6>
|
||||
<p>Create an object by defining a unique name.</p>
|
||||
</hgroup>
|
||||
<form hx-disable _="on submit
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||
<ul>
|
||||
<li>System</li>
|
||||
<li><span>System</span></li>
|
||||
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Logs</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
{% block body %}
|
||||
|
||||
<h4>System logs</h4>
|
||||
<h5>System logs</h5>
|
||||
|
||||
<div class="grid split-grid">
|
||||
<article>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||
<ul>
|
||||
<li>System</li>
|
||||
<li><span>System</span></li>
|
||||
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Settings</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
{% block body %}
|
||||
|
||||
<h4>Settings</h4>
|
||||
<h5>Settings</h5>
|
||||
|
||||
<div class="grid split-grid">
|
||||
<article>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||
<ul>
|
||||
<li>System</li>
|
||||
<li><span>System</span></li>
|
||||
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Status</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
{% 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-target="#system-status-cards"
|
||||
hx-select="#system-status-cards"
|
||||
@ -23,12 +23,12 @@
|
||||
hx-vals="">
|
||||
⟳ Refresh
|
||||
</a>
|
||||
</h4>
|
||||
</h5>
|
||||
|
||||
<div id="system-status">
|
||||
<section id="system-status-cards" class="grid-auto-cols">
|
||||
<article>
|
||||
<h5>{{ data.status.CLUSTER_PEERS_LOCAL.name }} 🏠</h5>
|
||||
<h6>{{ data.status.CLUSTER_PEERS_LOCAL.name }} 🏠</h6>
|
||||
<p>
|
||||
<small>This node.</small>
|
||||
</p>
|
||||
@ -46,7 +46,7 @@
|
||||
</article>
|
||||
{% for peer, peer_data in data.status.CLUSTER_PEERS_REMOTE_PEERS.items() %}
|
||||
<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>
|
||||
{% for k, v in peer_data %}
|
||||
<li><span class="color-zinc-600">{{ k.split("_")|join(" ")|capitalize }}</span>:
|
||||
@ -74,7 +74,7 @@
|
||||
<button type="submit"
|
||||
hx-trigger="click queue:first"
|
||||
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
|
||||
</button>
|
||||
{% else %}
|
||||
@ -85,7 +85,7 @@
|
||||
{% endif %}
|
||||
{% if ENFORCE_DBUPDATE and request.headers.get("Hx-Request") %}
|
||||
{# 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"
|
||||
class="button-red-800"
|
||||
id="enforce-dbupdate-button"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
hx-patch="/system/users/{{ user.id }}">
|
||||
|
||||
<article>
|
||||
<h5>General</h5>
|
||||
<h6>General</h6>
|
||||
<fieldset>
|
||||
<label>Login</label>
|
||||
<input name="login"
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
<article>
|
||||
<fieldset>
|
||||
<h5>ACL</h5>
|
||||
<h6>ACL</h6>
|
||||
{% for acl in USER_ACLS %}
|
||||
<input
|
||||
role="switch"
|
||||
@ -33,38 +33,39 @@
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h5>Credentials</h5>
|
||||
<h6>Credentials</h6>
|
||||
{% if not user.credentials %}
|
||||
<i>No credentials available</i>
|
||||
{% endif %}
|
||||
{% for hex_id, credential_data in user.credentials.items() %}
|
||||
{% for credential in user.credentials|sort(attribute='friendly_name', reverse=false) %}
|
||||
<section>
|
||||
<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()"
|
||||
contenteditable
|
||||
data-patch-parameter="friendly_name"
|
||||
spellcheck="false"
|
||||
hx-patch="/system/users/{{ user.id }}/credential/{{ hex_id }}"
|
||||
hx-patch="/system/users/{{ user.id }}/credential/{{ credential.id|hex }}"
|
||||
hx-trigger="editContent">
|
||||
{{- credential_data.friendly_name or 'John Doe' -}}
|
||||
{{- credential.friendly_name or 'John Doe' -}}
|
||||
</span>
|
||||
<a href="#" hx-disinherit="*" class="{{ "color-red" if not credential_data.active else "color-green"}}"
|
||||
hx-patch="/system/users/{{ user.id }}/credential/{{ hex_id }}"
|
||||
hx-vals='js:{"active": {{ "true" if not credential_data.active else "false"}}}'
|
||||
<a href="#" hx-disinherit="*" class="{{ "color-red" if not credential.active else "color-green"}}"
|
||||
hx-patch="/system/users/{{ user.id }}/credential/{{ credential.id|hex }}"
|
||||
hx-vals='js:{"active": {{ "true" if not credential.active else "false"}}}'
|
||||
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>
|
||||
<br>
|
||||
<small>Last Login:
|
||||
{% if credential_data.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>
|
||||
{% if credential.last_login %}
|
||||
<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 %}-
|
||||
{% endif %}
|
||||
</small>
|
||||
@ -74,8 +75,7 @@
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h5>Profile data</h5>
|
||||
|
||||
<h6>Profile data</h6>
|
||||
{% with
|
||||
schema=schemas.user_profile,
|
||||
current_data=user.profile,
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb" id="nav-breadcrumb" hx-swap-oob="true">
|
||||
<ul>
|
||||
<li>System</li>
|
||||
<li><span>System</span></li>
|
||||
<li><a href="#" hx-target="#body-main" hx-get="{{ request.path }}">Users</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
{% block body %}
|
||||
|
||||
<h4>Users</h4>
|
||||
<h5>Users</h5>
|
||||
|
||||
<details class="show-below-lg">
|
||||
<summary role="button" class="button-slate-800">Pending logins and registrations</summary>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user