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.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"]:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
|
||||||
]
|
|
||||||
|
|||||||
@ -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"])
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
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 %}
|
{% 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 %}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
#}
|
#}
|
||||||
|
|
||||||
|
<div id="register-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"
|
||||||
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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 }}"
|
||||||
|
|||||||
@ -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
|
||||||
">💡 Theme
|
">💡
|
||||||
</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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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 %}
|
{% 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>
|
||||||
|
|||||||
@ -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" %}
|
||||||
|
|||||||
@ -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>✏️ <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
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 |
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user