bazaar-contrib-server-access 0.3.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bazaar_contrib_server_access/__init__.py +71 -0
- bazaar_contrib_server_access/accounts.py +120 -0
- bazaar_contrib_server_access/login.py +118 -0
- bazaar_contrib_server_access/py.typed +0 -0
- bazaar_contrib_server_access/resources/locales/en.toml +8 -0
- bazaar_contrib_server_access/resources/locales/zh-CN.toml +8 -0
- bazaar_contrib_server_access/resources/static/access.css +98 -0
- bazaar_contrib_server_access/resources/templates/access/login.html +17 -0
- bazaar_contrib_server_access/resources/templates/access/login_form.html +6 -0
- bazaar_contrib_server_access/resources/templates/access/street.html +9 -0
- bazaar_contrib_server_access/resources/templates/access/street_phone.html +9 -0
- bazaar_contrib_server_access/resources/templates/access/triangle-alert.svg +1 -0
- bazaar_contrib_server_access/schema.py +164 -0
- bazaar_contrib_server_access/secrets.py +64 -0
- bazaar_contrib_server_access/sessions.py +82 -0
- bazaar_contrib_server_access-0.3.0rc1.dist-info/METADATA +34 -0
- bazaar_contrib_server_access-0.3.0rc1.dist-info/RECORD +20 -0
- bazaar_contrib_server_access-0.3.0rc1.dist-info/WHEEL +4 -0
- bazaar_contrib_server_access-0.3.0rc1.dist-info/entry_points.txt +3 -0
- bazaar_contrib_server_access-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Sessions and the login page the server's pages and APIs are behind."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
lazy import asyncio
|
|
7
|
+
lazy import logging
|
|
8
|
+
lazy import os
|
|
9
|
+
lazy from collections.abc import AsyncIterator
|
|
10
|
+
lazy from importlib.resources import files
|
|
11
|
+
lazy from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from bazaar_contrib_server import SERVER
|
|
14
|
+
from bazaar_contrib_server_database import SERVER_DB, SERVER_SCHEMA
|
|
15
|
+
from bazaar_contrib_server_migrations import SERVER_MIGRATIONS
|
|
16
|
+
from bazaar_core import Context, Contrib
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
lazy from .accounts import ADMIN, Accounts
|
|
20
|
+
lazy from .login import Login
|
|
21
|
+
lazy from .schema import MODULE
|
|
22
|
+
lazy from .sessions import Sessions, key
|
|
23
|
+
|
|
24
|
+
__all__ = ["AccessConfig", "access"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AccessConfig(BaseModel):
|
|
28
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
29
|
+
|
|
30
|
+
# how long a login lasts before the person is asked again
|
|
31
|
+
session_minutes: int = Field(default=10, ge=1, strict=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@asynccontextmanager
|
|
35
|
+
async def _access(ctx: Context, config: AccessConfig) -> AsyncIterator[None]:
|
|
36
|
+
server = ctx.accept(SERVER)
|
|
37
|
+
async with ctx.accept(SERVER_SCHEMA).register(MODULE):
|
|
38
|
+
home = Path(os.environ.get("BCN_HOME") or await asyncio.to_thread(Path.home))
|
|
39
|
+
sessions = Sessions(await asyncio.to_thread(key, home / ".bcn" / "session.key"))
|
|
40
|
+
accounts = await Accounts.open(ctx.accept(SERVER_DB))
|
|
41
|
+
password = await accounts.ensure_admin()
|
|
42
|
+
if password is not None:
|
|
43
|
+
# the only time the password exists in the clear; it goes where
|
|
44
|
+
# whoever started the server is looking
|
|
45
|
+
logging.getLogger(__name__).warning(
|
|
46
|
+
"created account %s with password %s; log in and change it",
|
|
47
|
+
ADMIN,
|
|
48
|
+
password,
|
|
49
|
+
)
|
|
50
|
+
resources = Path(str(files(__name__))) / "resources"
|
|
51
|
+
async with (
|
|
52
|
+
server.module(
|
|
53
|
+
"access", static=resources / "static", locales=resources / "locales"
|
|
54
|
+
),
|
|
55
|
+
server.login(
|
|
56
|
+
Login(
|
|
57
|
+
await server.pages("access", __name__),
|
|
58
|
+
sessions,
|
|
59
|
+
accounts,
|
|
60
|
+
config.session_minutes,
|
|
61
|
+
)
|
|
62
|
+
),
|
|
63
|
+
):
|
|
64
|
+
yield
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
access = Contrib(
|
|
68
|
+
config=AccessConfig,
|
|
69
|
+
accepts=[SERVER, SERVER_DB, SERVER_SCHEMA, SERVER_MIGRATIONS],
|
|
70
|
+
lifespan=_access,
|
|
71
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The accounts that may log in, read from the server's database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
lazy from time import time_ns
|
|
7
|
+
lazy from typing import cast
|
|
8
|
+
lazy from uuid import uuid7
|
|
9
|
+
|
|
10
|
+
lazy from bazaar_contrib_storage import (
|
|
11
|
+
Column,
|
|
12
|
+
Compare,
|
|
13
|
+
Database,
|
|
14
|
+
Insert,
|
|
15
|
+
Record,
|
|
16
|
+
Select,
|
|
17
|
+
Value,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .secrets import derive, hash_secret, new_secret, verify_secret
|
|
21
|
+
|
|
22
|
+
# the account made at first start; on its own, the only one there is
|
|
23
|
+
ADMIN = "admin"
|
|
24
|
+
# base64url of this many bytes: long enough that printing it once is safe
|
|
25
|
+
_PASSWORD_BYTES = 24
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class Stored:
|
|
30
|
+
id: str
|
|
31
|
+
name: str
|
|
32
|
+
password_hash: str
|
|
33
|
+
language: str | None
|
|
34
|
+
theme: str | None
|
|
35
|
+
auth_type: str
|
|
36
|
+
session_version: int
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def admin(self) -> bool:
|
|
40
|
+
return self.name == ADMIN and self.auth_type == "local"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _stored(row: Record) -> Stored:
|
|
44
|
+
return Stored(
|
|
45
|
+
id=cast(str, row["id"]),
|
|
46
|
+
name=cast(str, row["name"]),
|
|
47
|
+
password_hash=cast(str, row["password_hash"]),
|
|
48
|
+
language=cast(str | None, row["language"]),
|
|
49
|
+
theme=cast(str | None, row["theme"]),
|
|
50
|
+
auth_type=cast(str, row["auth_type"]),
|
|
51
|
+
session_version=cast(int, row["session_version"]),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Accounts:
|
|
56
|
+
def __init__(self, database: Database, nobody: str) -> None:
|
|
57
|
+
self._database = database
|
|
58
|
+
# a hash nobody's password matches, verified for names nobody has
|
|
59
|
+
self._nobody = nobody
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
async def open(cls, database: Database) -> Accounts:
|
|
63
|
+
return cls(database, await derive(hash_secret, new_secret()))
|
|
64
|
+
|
|
65
|
+
async def get(self, account_id: str) -> Stored | None:
|
|
66
|
+
return await self._one("id", account_id)
|
|
67
|
+
|
|
68
|
+
async def named(self, name: str) -> Stored | None:
|
|
69
|
+
return await self._one("name", name)
|
|
70
|
+
|
|
71
|
+
async def ensure_admin(self) -> str | None:
|
|
72
|
+
"""The password the admin was just given, when it is new; it is shown
|
|
73
|
+
once and never kept in the clear."""
|
|
74
|
+
if await self.named(ADMIN) is not None:
|
|
75
|
+
return None
|
|
76
|
+
password = new_secret(_PASSWORD_BYTES)
|
|
77
|
+
password_hash = await derive(hash_secret, password)
|
|
78
|
+
await self._database.write(
|
|
79
|
+
lambda session: session.insert(
|
|
80
|
+
Insert(
|
|
81
|
+
table="accounts",
|
|
82
|
+
rows=(
|
|
83
|
+
{
|
|
84
|
+
"id": str(uuid7()),
|
|
85
|
+
"name": ADMIN,
|
|
86
|
+
"password_hash": password_hash,
|
|
87
|
+
"created_at_ms": time_ns() // 1_000_000,
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return password
|
|
94
|
+
|
|
95
|
+
async def verify(self, name: str, password: str) -> Stored | None:
|
|
96
|
+
account = await self.named(name)
|
|
97
|
+
# an unknown name costs the same as a wrong password, so neither can
|
|
98
|
+
# be told apart by timing
|
|
99
|
+
stored = (
|
|
100
|
+
self._nobody
|
|
101
|
+
if account is None or account.auth_type != "local"
|
|
102
|
+
else account.password_hash
|
|
103
|
+
)
|
|
104
|
+
if not await derive(verify_secret, password, stored):
|
|
105
|
+
return None
|
|
106
|
+
return account
|
|
107
|
+
|
|
108
|
+
async def _one(self, column: str, value: str) -> Stored | None:
|
|
109
|
+
rows = await self._database.read(
|
|
110
|
+
lambda session: session.select(
|
|
111
|
+
Select(
|
|
112
|
+
table="accounts",
|
|
113
|
+
where=Compare(
|
|
114
|
+
left=Column(name=column), operator="=", right=Value(value=value)
|
|
115
|
+
),
|
|
116
|
+
limit=1,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return _stored(rows[0]) if rows else None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Logging in and out, and who each request behind the login is from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
lazy from bazaar_contrib_server import Pages
|
|
8
|
+
lazy from pydantic import BaseModel, ValidationError
|
|
9
|
+
lazy from starlette.requests import HTTPConnection, Request
|
|
10
|
+
lazy from starlette.responses import RedirectResponse, Response
|
|
11
|
+
lazy from starlette.routing import Route
|
|
12
|
+
lazy from starlette.types import ASGIApp
|
|
13
|
+
|
|
14
|
+
lazy from .accounts import Accounts
|
|
15
|
+
lazy from .sessions import COOKIE, Sessions
|
|
16
|
+
|
|
17
|
+
LOGIN = "/login/"
|
|
18
|
+
HOME = "/"
|
|
19
|
+
# the hint OIDC leaves for signing in again without being asked
|
|
20
|
+
REAUTH = "bcs_reauth"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Viewer:
|
|
25
|
+
"""An account as the server's pages see it."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
theme: str | None
|
|
30
|
+
language: str | None
|
|
31
|
+
admin: bool
|
|
32
|
+
|
|
33
|
+
def allows(self, point: str) -> bool:
|
|
34
|
+
del point
|
|
35
|
+
return self.admin
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Credentials(BaseModel):
|
|
39
|
+
name: str
|
|
40
|
+
password: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Login:
|
|
44
|
+
"""The login module the server's pages and APIs are behind."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, pages: Pages, sessions: Sessions, accounts: Accounts, minutes: int
|
|
48
|
+
) -> None:
|
|
49
|
+
self._pages = pages
|
|
50
|
+
self._sessions = sessions
|
|
51
|
+
self._accounts = accounts
|
|
52
|
+
self._minutes = minutes
|
|
53
|
+
self.app: ASGIApp = pages.application(
|
|
54
|
+
[
|
|
55
|
+
Route("/", self._form),
|
|
56
|
+
Route("/", self._login, methods=["POST"]),
|
|
57
|
+
Route("/logout", self._logout, methods=["POST"]),
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def account(self, connection: HTTPConnection) -> Viewer | Response:
|
|
62
|
+
claim = self._sessions.read(connection.cookies.get(COOKIE))
|
|
63
|
+
stored = None if claim is None else await self._accounts.get(claim.account_id)
|
|
64
|
+
if (
|
|
65
|
+
claim is None
|
|
66
|
+
or stored is None
|
|
67
|
+
or claim.session_version != stored.session_version
|
|
68
|
+
):
|
|
69
|
+
# htmx cannot follow a redirect into another page; it is told to go
|
|
70
|
+
if connection.headers.get("HX-Request") == "true":
|
|
71
|
+
return Response(status_code=401, headers={"HX-Redirect": LOGIN})
|
|
72
|
+
return RedirectResponse(LOGIN, status_code=303)
|
|
73
|
+
return Viewer(
|
|
74
|
+
id=stored.id,
|
|
75
|
+
name=stored.name,
|
|
76
|
+
theme=stored.theme,
|
|
77
|
+
language=stored.language,
|
|
78
|
+
admin=stored.admin,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def _form(self, request: Request) -> Response:
|
|
82
|
+
return await self._pages.render(request, "access/login.html", failed=False)
|
|
83
|
+
|
|
84
|
+
async def _login(self, request: Request) -> Response:
|
|
85
|
+
try:
|
|
86
|
+
given = _Credentials.model_validate(dict(await request.form()))
|
|
87
|
+
except ValidationError:
|
|
88
|
+
given = None
|
|
89
|
+
stored = (
|
|
90
|
+
None
|
|
91
|
+
if given is None
|
|
92
|
+
else await self._accounts.verify(given.name.strip(), given.password)
|
|
93
|
+
)
|
|
94
|
+
if stored is None:
|
|
95
|
+
# a failed login is a fragment too: htmx swaps it into the form
|
|
96
|
+
return await self._pages.render(
|
|
97
|
+
request, "access/login_form.html", status_code=401, failed=True
|
|
98
|
+
)
|
|
99
|
+
response = Response(status_code=204, headers={"HX-Redirect": HOME})
|
|
100
|
+
response.delete_cookie(REAUTH, path="/")
|
|
101
|
+
response.set_cookie(
|
|
102
|
+
COOKIE,
|
|
103
|
+
self._sessions.issue(stored.id, stored.session_version, self._minutes),
|
|
104
|
+
max_age=self._minutes * 60,
|
|
105
|
+
path="/",
|
|
106
|
+
httponly=True,
|
|
107
|
+
samesite="lax",
|
|
108
|
+
# a cookie issued over https never travels over plain http
|
|
109
|
+
secure=request.url.scheme == "https",
|
|
110
|
+
)
|
|
111
|
+
return response
|
|
112
|
+
|
|
113
|
+
async def _logout(self, request: Request) -> Response:
|
|
114
|
+
del request
|
|
115
|
+
response = Response(status_code=204, headers={"HX-Redirect": LOGIN})
|
|
116
|
+
response.delete_cookie(COOKIE, path="/")
|
|
117
|
+
response.delete_cookie(REAUTH, path="/")
|
|
118
|
+
return response
|
|
File without changes
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"access.login.title" = "Log in"
|
|
2
|
+
"access.login.welcome" = "Welcome back. Choose a way into the bazaar."
|
|
3
|
+
"access.login.name" = "Account"
|
|
4
|
+
"access.login.password" = "Password"
|
|
5
|
+
"access.login.submit" = "Log in"
|
|
6
|
+
"access.login.failed" = "Incorrect account or password."
|
|
7
|
+
"access.login.slogan" = "Don't build alone —<br>bring your <span>agents</span> to the bazaar."
|
|
8
|
+
"access.login.tagline" = "Bring your own agents to the bazaar, and build with your teams in the open."
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"access.login.title" = "登录"
|
|
2
|
+
"access.login.welcome" = "欢迎回来。选择一种方式进入集市。"
|
|
3
|
+
"access.login.name" = "账号"
|
|
4
|
+
"access.login.password" = "密码"
|
|
5
|
+
"access.login.submit" = "登录"
|
|
6
|
+
"access.login.failed" = "账号或密码不正确。"
|
|
7
|
+
"access.login.slogan" = "不必独自建造——<br>带上你的<span>智能体</span>,来集市。"
|
|
8
|
+
"access.login.tagline" = "Bring your own agents to the bazaar, and build with your teams in the open."
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* The login page: the market street beside the form on a wide screen, below
|
|
2
|
+
it on a narrow one. Every colour is the theme's; the street's come from its
|
|
3
|
+
scene tokens, mixed for shade and light, never graded. */
|
|
4
|
+
|
|
5
|
+
html, body { height: 100%; }
|
|
6
|
+
body { background: var(--bg); }
|
|
7
|
+
.login { display: grid; grid-template-columns: 1fr 600px; min-height: 100vh; min-height: 100dvh; }
|
|
8
|
+
|
|
9
|
+
.art { position: relative; overflow: hidden; background: var(--scene-sky); }
|
|
10
|
+
.art svg.street-art { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
11
|
+
.copy { position: absolute; z-index: 1; }
|
|
12
|
+
.art .copy { left: 72px; top: 300px; right: 72px; }
|
|
13
|
+
.copy .eyebrow { font: 700 12px var(--font-ui); letter-spacing: .32em; color: var(--saffron); }
|
|
14
|
+
.copy h1, .copy h2 { margin: 18px 0 0; font-family: var(--font-display); font-weight: 900; font-size: 52px; line-height: 1.22; color: var(--text); letter-spacing: .02em; }
|
|
15
|
+
.copy h1 span, .copy h2 span { color: var(--saffron); }
|
|
16
|
+
.copy p { margin: 20px 0 0; font: italic 400 19px/1.5 var(--font-display); color: var(--text-2); font-variation-settings: "opsz" 24, "SOFT" 80; max-width: 520px; }
|
|
17
|
+
|
|
18
|
+
.panel { background: var(--surface); border-left: 1px solid var(--line); display: flex; flex-direction: column; padding: 40px 72px 28px; }
|
|
19
|
+
.brand { display: flex; align-items: center; gap: 10px; }
|
|
20
|
+
.brand .mark { width: 30px; height: 30px; }
|
|
21
|
+
.brand .word { font-family: var(--font-display); font-weight: 650; font-size: 21px; letter-spacing: -.01em; font-variation-settings: "opsz" 72, "SOFT" 60; }
|
|
22
|
+
.panel h2 { margin: 54px 0 6px; font-family: var(--font-display); font-size: 32px; font-weight: 650; letter-spacing: -.01em; }
|
|
23
|
+
.panel .sub { color: var(--muted); font-size: 14px; }
|
|
24
|
+
|
|
25
|
+
/* the form: labels above, quiet fields, one flat amber button */
|
|
26
|
+
.signin { display: grid; gap: 18px; margin-top: 30px; }
|
|
27
|
+
.fld { display: grid; gap: 8px; }
|
|
28
|
+
.fld label { font-size: 13px; font-weight: 500; color: var(--text-2); }
|
|
29
|
+
.fld input { height: 46px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--line-soft); background: var(--sunken); color: var(--text); font: 15px var(--font-ui); outline: 0; }
|
|
30
|
+
.fld input:focus { border-color: var(--saffron-line); box-shadow: 0 0 0 3px var(--saffron-soft); }
|
|
31
|
+
.fld input[type="password"] { letter-spacing: .14em; }
|
|
32
|
+
.err { display: flex; align-items: center; gap: 7px; margin: 0; color: var(--crimson); font-size: 13px; }
|
|
33
|
+
.err .i svg { width: 14px; height: 14px; }
|
|
34
|
+
.go { margin-top: 6px; height: 46px; border: 0; border-radius: 10px; font: 600 15px var(--font-ui); background: var(--saffron); color: var(--saffron-ink); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .22), inset 0 -1px 0 rgba(0, 0, 0, .14); }
|
|
35
|
+
|
|
36
|
+
.band { display: none; }
|
|
37
|
+
|
|
38
|
+
/* narrow screens: the form first, the street under a downward arc */
|
|
39
|
+
@media (max-width: 959px) {
|
|
40
|
+
.login { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
|
41
|
+
.art { display: none; }
|
|
42
|
+
.panel { border-left: 0; padding: 26px 22px 10px; }
|
|
43
|
+
.panel h2 { margin: 18px 0 4px; font-size: 26px; }
|
|
44
|
+
.panel .sub { font-size: 13.5px; }
|
|
45
|
+
.signin { gap: 14px; margin-top: 20px; }
|
|
46
|
+
.fld input { font-size: 16px; }
|
|
47
|
+
.band { display: block; position: relative; min-height: 270px; overflow: hidden; background: var(--scene-sky); }
|
|
48
|
+
.band svg.street-top { position: absolute; left: 0; top: 0; width: 100%; height: auto; }
|
|
49
|
+
.band svg.street-bottom { position: absolute; left: 0; bottom: 0; width: 100%; height: auto; }
|
|
50
|
+
.band .copy { left: 22px; right: 22px; bottom: 178px; }
|
|
51
|
+
.band .eyebrow { font-size: 11px; letter-spacing: .3em; }
|
|
52
|
+
.band h2 { margin-top: 10px; font-size: 20px; line-height: 1.3; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* the street */
|
|
56
|
+
.s-sky { fill: var(--scene-sky); }
|
|
57
|
+
.s-edge { fill: var(--surface); }
|
|
58
|
+
.s-star { fill: var(--scene-star); }
|
|
59
|
+
.s-moon { fill: var(--scene-moon); }
|
|
60
|
+
.s-bite { fill: var(--scene-bite); }
|
|
61
|
+
.s-far { fill: var(--scene-far); }
|
|
62
|
+
.s-far2 { fill: var(--scene-far-roof); }
|
|
63
|
+
.s-window { fill: var(--scene-window); }
|
|
64
|
+
.s-cord { stroke: var(--scene-cord); stroke-width: 1.4; fill: none; }
|
|
65
|
+
.s-cap { fill: var(--scene-cap); }
|
|
66
|
+
.s-rib { stroke: rgba(0, 0, 0, .2); stroke-width: 1; fill: none; }
|
|
67
|
+
.s-wood { fill: var(--scene-wood); }
|
|
68
|
+
.s-wood-light { fill: var(--scene-wood-light); }
|
|
69
|
+
.s-wood-shade { fill: var(--scene-wood-shade); }
|
|
70
|
+
.s-inside { fill: var(--scene-inside); }
|
|
71
|
+
.s-inside-shade { fill: var(--scene-inside-shade); }
|
|
72
|
+
.s-alt { fill: var(--scene-alt); }
|
|
73
|
+
.s-alt-shade { fill: color-mix(in srgb, var(--scene-alt) 78%, #000); }
|
|
74
|
+
.s-bulb { fill: var(--scene-bulb); }
|
|
75
|
+
.s-ground { fill: var(--scene-ground); }
|
|
76
|
+
.s-ground-light { fill: var(--scene-ground-light); }
|
|
77
|
+
.s-ground-shade { fill: var(--scene-ground-shade); }
|
|
78
|
+
.s-pot { fill: var(--scene-pot); }
|
|
79
|
+
.s-pot-shade { fill: color-mix(in srgb, var(--scene-pot) 70%, #000); }
|
|
80
|
+
.s-steam { stroke: var(--scene-steam); stroke-width: 4; stroke-linecap: round; }
|
|
81
|
+
.f-paper, .f-cap, .f-cord, .f-rib { opacity: var(--scene-far-fade); }
|
|
82
|
+
.s-saffron { fill: var(--saffron); } .s-jade { fill: var(--jade); } .s-crimson { fill: var(--crimson); }
|
|
83
|
+
.s-indigo { fill: var(--indigo); } .s-plum { fill: var(--plum); }
|
|
84
|
+
.s-saffron-shade { fill: color-mix(in srgb, var(--saffron) 70%, #000); }
|
|
85
|
+
.s-jade-shade { fill: color-mix(in srgb, var(--jade) 70%, #000); }
|
|
86
|
+
.s-crimson-shade { fill: color-mix(in srgb, var(--crimson) 70%, #000); }
|
|
87
|
+
.s-indigo-shade { fill: color-mix(in srgb, var(--indigo) 70%, #000); }
|
|
88
|
+
.s-plum-shade { fill: color-mix(in srgb, var(--plum) 70%, #000); }
|
|
89
|
+
.s-saffron-glow { fill: color-mix(in srgb, var(--saffron) 45%, var(--scene-glow)); }
|
|
90
|
+
.s-jade-glow { fill: color-mix(in srgb, var(--jade) 45%, var(--scene-glow)); }
|
|
91
|
+
.s-crimson-glow { fill: color-mix(in srgb, var(--crimson) 45%, var(--scene-glow)); }
|
|
92
|
+
.s-indigo-glow { fill: color-mix(in srgb, var(--indigo) 45%, var(--scene-glow)); }
|
|
93
|
+
.s-plum-glow { fill: color-mix(in srgb, var(--plum) 45%, var(--scene-glow)); }
|
|
94
|
+
.s-saffron-dim { fill: color-mix(in srgb, var(--saffron) 60%, var(--scene-inside)); }
|
|
95
|
+
.s-jade-dim { fill: color-mix(in srgb, var(--jade) 60%, var(--scene-inside)); }
|
|
96
|
+
.s-crimson-dim { fill: color-mix(in srgb, var(--crimson) 60%, var(--scene-inside)); }
|
|
97
|
+
.s-indigo-dim { fill: color-mix(in srgb, var(--indigo) 60%, var(--scene-inside)); }
|
|
98
|
+
.s-plum-dim { fill: color-mix(in srgb, var(--plum) 60%, var(--scene-inside)); }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{% extends "server/base.html" %}
|
|
2
|
+
{# the login page fills the window, whatever frame it was asked for in #}
|
|
3
|
+
{% block root %} data-top{% endblock %}
|
|
4
|
+
{% block title %}{{ t.text("access.login.title") }}{% endblock %}
|
|
5
|
+
{% block head %}<link rel="stylesheet" href="{{ asset('access.css') }}">{% endblock %}
|
|
6
|
+
{% block body %}
|
|
7
|
+
<div class="login">
|
|
8
|
+
{% include "access/street.html" %}
|
|
9
|
+
<div class="panel">
|
|
10
|
+
<div class="brand">{% include "server/mark.svg" %}<span class="word">Bazaar</span></div>
|
|
11
|
+
<h2>{{ t.text("access.login.title") }}</h2>
|
|
12
|
+
<div class="sub">{{ t.text("access.login.welcome") }}</div>
|
|
13
|
+
{% include "access/login_form.html" %}
|
|
14
|
+
</div>
|
|
15
|
+
{% include "access/street_phone.html" %}
|
|
16
|
+
</div>
|
|
17
|
+
{% endblock %}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<form class="signin" hx-post="/login/" hx-target="this" hx-swap="outerHTML">
|
|
2
|
+
<div class="fld"><label for="name">{{ t.text("access.login.name") }}</label><input id="name" name="name" required autocomplete="username" spellcheck="false" autocapitalize="off"></div>
|
|
3
|
+
<div class="fld"><label for="password">{{ t.text("access.login.password") }}</label><input id="password" name="password" type="password" required autocomplete="current-password"></div>
|
|
4
|
+
{% if failed %}<p class="err" role="alert"><span class="i">{% include "access/triangle-alert.svg" %}</span>{{ t.text("access.login.failed") }}</p>{% endif %}
|
|
5
|
+
<button class="go" type="submit">{{ t.text("access.login.submit") }}</button>
|
|
6
|
+
</form>
|