SHA256
730 lines
27 KiB
Python
730 lines
27 KiB
Python
import os
|
|
import logging
|
|
from functools import wraps
|
|
|
|
from flask import Flask, request, jsonify, session, render_template_string, redirect, url_for
|
|
|
|
from backend import storage
|
|
|
|
|
|
SENSITIVE_FIELDS = {"password", "passwd", "secret", "token", "access_token", "refresh_token"}
|
|
"""Redact sensitive values and long blobs from log-friendly repr."""
|
|
|
|
|
|
def redact_for_log(value):
|
|
if isinstance(value, dict):
|
|
return {k: "[redacted]" if k.lower() in SENSITIVE_FIELDS else redact_for_log(v) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [redact_for_log(v) for v in value]
|
|
return value
|
|
|
|
|
|
def _request_snapshot(request):
|
|
body = {}
|
|
try:
|
|
if request.is_json:
|
|
body = request.get_json(silent=True) or {}
|
|
elif request.method in ("POST", "PUT", "PATCH") and request.form:
|
|
body = {k: v for k, v in request.form.items()}
|
|
except Exception:
|
|
body = {}
|
|
return {
|
|
"method": request.method,
|
|
"path": request.path,
|
|
"args": dict(request.args),
|
|
"body": redact_for_log(body),
|
|
}
|
|
|
|
|
|
def _maybe_log_request(app, request):
|
|
if app.debug:
|
|
app.logger.debug("request: %s", _request_snapshot(request))
|
|
|
|
|
|
ARTICLE_TYPE_LABELS = {
|
|
"contact_details": "Contact details",
|
|
"notes": "Notes",
|
|
"profile_text": "Profile text",
|
|
"documents": "Documents",
|
|
"links": "Links",
|
|
"phone_numbers": "Phone numbers",
|
|
"addresses": "Addresses",
|
|
"social_profiles": "Social profiles",
|
|
}
|
|
|
|
|
|
def create_app():
|
|
app = Flask(__name__, static_folder="static", static_url_path="/static")
|
|
app.secret_key = os.environ.get("FLASK_SECRET_KEY") or os.urandom(24).hex()
|
|
storage.init_db()
|
|
|
|
@app.route("/")
|
|
def index():
|
|
if request.args:
|
|
# Never accept credentials (or anything else) via the query string.
|
|
app.logger.warning("ignoring query string on index: keys=%s", list(request.args.keys()))
|
|
account = session.get("account")
|
|
if account:
|
|
fresh = storage.get_account_by_id(account.get("id")) or account
|
|
session["account"] = fresh
|
|
return render_index(fresh)
|
|
return render_login_page()
|
|
|
|
def _wants_json():
|
|
if request.is_json:
|
|
return True
|
|
if request.form:
|
|
return False
|
|
best = request.accept_mimetypes.best_match(["application/json", "text/html"])
|
|
return best == "application/json"
|
|
|
|
def _form_or_json(field):
|
|
if field in request.form:
|
|
return request.form.get(field)
|
|
if request.is_json:
|
|
data = request.get_json(silent=True) or {}
|
|
return data.get(field)
|
|
return None
|
|
|
|
@app.route("/login", methods=["GET", "POST"])
|
|
def login():
|
|
if request.method == "GET":
|
|
if request.args:
|
|
app.logger.warning("ignoring query string on login: keys=%s", list(request.args.keys()))
|
|
return render_login_page()
|
|
_maybe_log_request(app, request)
|
|
email = _form_or_json("email")
|
|
password = _form_or_json("password")
|
|
wants_json = _wants_json()
|
|
if not email or not password:
|
|
app.logger.warning("login attempt missing credentials: path=%s", request.path)
|
|
if wants_json:
|
|
return reply_error("email and password required")
|
|
return render_login_page("email and password required")
|
|
account = storage.authenticate(email, password)
|
|
if not account:
|
|
app.logger.info("login failed for email=%s path=%s", email, request.path)
|
|
if wants_json:
|
|
return reply_error("invalid credentials")
|
|
return render_login_page("invalid credentials")
|
|
session["account"] = account
|
|
app.logger.info("login ok for email=%s path=%s", email, request.path)
|
|
if wants_json:
|
|
return reply_ok(account)
|
|
return redirect("/")
|
|
|
|
@app.route("/logout")
|
|
def logout():
|
|
session.pop("account", None)
|
|
return redirect("/")
|
|
|
|
@app.route("/account/create", methods=["GET", "POST"])
|
|
def create_account_page():
|
|
if request.method == "GET":
|
|
return render_account_create_page()
|
|
_maybe_log_request(app, request)
|
|
return _create_account()
|
|
|
|
@app.route("/account", methods=["POST"])
|
|
def create_account_route():
|
|
return _create_account()
|
|
|
|
def _create_account():
|
|
email = _form_or_json("email")
|
|
password = _form_or_json("password")
|
|
wants_json = _wants_json()
|
|
if not email or not password:
|
|
if wants_json:
|
|
return reply_error("email and password required")
|
|
return render_account_create_page("email and password required")
|
|
try:
|
|
account = storage.create_account(email, password)
|
|
app.logger.info("account created email=%s path=%s", email, request.path)
|
|
if wants_json:
|
|
return reply_ok(account)
|
|
return redirect("/")
|
|
except ValueError as e:
|
|
if "already exists" in str(e).lower():
|
|
app.logger.info("account exists email=%s path=%s", email, request.path)
|
|
else:
|
|
app.logger.warning("account create failed email=%s err=%s path=%s", email, str(e), request.path)
|
|
if wants_json:
|
|
return reply_error(str(e))
|
|
return render_account_create_page(str(e))
|
|
|
|
@app.route("/account/me")
|
|
def me():
|
|
account = session.get("account")
|
|
if not account:
|
|
return reply_error("not authenticated")
|
|
fresh = storage.get_account_by_id(account.get("id")) or account
|
|
session["account"] = fresh
|
|
return reply_ok(fresh)
|
|
|
|
def get_account():
|
|
return session.get("account")
|
|
|
|
def login_required(f):
|
|
@wraps(f)
|
|
def wrapped(*args, **kwargs):
|
|
account = get_account()
|
|
if not account:
|
|
return reply_error("authentication required")
|
|
request.account = account
|
|
return f(*args, **kwargs)
|
|
|
|
return wrapped
|
|
|
|
@app.route("/persons", methods=["GET"])
|
|
@login_required
|
|
def list_persons_route():
|
|
persons = storage.list_persons(request.account)
|
|
return reply_ok(persons)
|
|
|
|
@app.route("/persons", methods=["POST"])
|
|
@login_required
|
|
def create_person_route():
|
|
name = _form_or_json("name") or ""
|
|
try:
|
|
person = storage.create_person(request.account, name)
|
|
except (PermissionError, ValueError) as e:
|
|
return reply_error(str(e))
|
|
return reply_ok(person)
|
|
|
|
@app.route("/admin/persons", methods=["POST"])
|
|
@login_required
|
|
def admin_create_person_route():
|
|
name = _form_or_json("name") or ""
|
|
email = _form_or_json("email")
|
|
password = _form_or_json("password")
|
|
try:
|
|
person = storage.create_person_with_account(request.account, name, email, password)
|
|
return reply_ok(person)
|
|
except (PermissionError, ValueError) as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/persons/<person_id>")
|
|
@login_required
|
|
def get_person_route(person_id):
|
|
person = storage.get_person(person_id)
|
|
if not person:
|
|
return reply_error("person not found")
|
|
if not storage.is_admin(request.account) and person_id != request.account.get("person_id"):
|
|
# Only admins see which emails belong to another person.
|
|
person = dict(person)
|
|
person["emails"] = []
|
|
return reply_ok(person)
|
|
|
|
@app.route("/persons/<person_id>", methods=["PATCH"])
|
|
@login_required
|
|
def update_person_route(person_id):
|
|
name = request.form.get("name") or request.json.get("name")
|
|
name = name if name is not None else None
|
|
person = storage.update_person(request.account, person_id, name)
|
|
if not person:
|
|
return reply_error("person not found")
|
|
return reply_ok(person)
|
|
|
|
@app.route("/persons/<person_id>", methods=["DELETE"])
|
|
@login_required
|
|
def delete_person_route(person_id):
|
|
ok = storage.delete_person(request.account, person_id)
|
|
if not ok:
|
|
return reply_error("person not found")
|
|
return reply_ok({"deleted": True})
|
|
|
|
@app.route("/persons/<person_id>/emails/attach", methods=["POST"])
|
|
@login_required
|
|
def attach_email_route(person_id):
|
|
email = request.form.get("email") or request.json.get("email")
|
|
if not email:
|
|
return reply_error("email required")
|
|
try:
|
|
person = storage.attach_email_to_person(request.account, person_id, email)
|
|
return reply_ok(person)
|
|
except (ValueError, PermissionError) as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/persons/<person_id>/emails/detach", methods=["POST"])
|
|
@login_required
|
|
def detach_email_route(person_id):
|
|
email = request.form.get("email") or request.json.get("email")
|
|
if not email:
|
|
return reply_error("email required")
|
|
try:
|
|
person = storage.detach_email_from_person(request.account, person_id, email)
|
|
return reply_ok(person)
|
|
except (ValueError, PermissionError) as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/articles", methods=["GET"])
|
|
@login_required
|
|
def list_articles_route():
|
|
person_id = request.args.get("person_id")
|
|
articles = storage.list_articles(request.account, person_id)
|
|
return reply_ok(articles)
|
|
|
|
@app.route("/articles", methods=["POST"])
|
|
@login_required
|
|
def create_article_route():
|
|
person_id = _form_or_json("person_id")
|
|
article_type = _form_or_json("article_type")
|
|
title = _form_or_json("title") or ""
|
|
content = _form_or_json("content") or ""
|
|
visibility = _form_or_json("visibility") or "private"
|
|
group_ids = request.json.get("group_ids") if request.is_json else None
|
|
if group_ids is None:
|
|
group_ids = []
|
|
if article_type not in storage.ARTICLE_TYPES:
|
|
return reply_error("invalid article type")
|
|
if person_id is None:
|
|
return reply_error("person_id required")
|
|
try:
|
|
article = storage.create_article(
|
|
request.account, person_id, article_type, title, content, visibility, group_ids
|
|
)
|
|
return reply_ok(article)
|
|
except (ValueError, PermissionError) as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/articles/<article_id>")
|
|
@login_required
|
|
def get_article_route(article_id):
|
|
article = storage.get_article(article_id)
|
|
if not article:
|
|
return reply_error("article not found")
|
|
if not storage._can_read_article(request.account, article):
|
|
article = {k: v for k, v in article.items() if k != "content"}
|
|
return reply_ok(article)
|
|
|
|
@app.route("/articles/<article_id>", methods=["PATCH"])
|
|
@login_required
|
|
def update_article_route(article_id):
|
|
title = request.form.get("title") or request.json.get("title")
|
|
content = request.form.get("content") or request.json.get("content")
|
|
visibility = request.form.get("visibility") or request.json.get("visibility")
|
|
group_ids = request.json.get("group_ids") if request.is_json else None
|
|
title = title if title is not None else None
|
|
content = content if content is not None else None
|
|
visibility = visibility if visibility is not None else None
|
|
try:
|
|
article = storage.update_article(
|
|
request.account, article_id, title=title, content=content, visibility=visibility, group_ids=group_ids
|
|
)
|
|
if not article:
|
|
return reply_error("article not found")
|
|
return reply_ok(article)
|
|
except (ValueError, PermissionError) as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/articles/<article_id>", methods=["DELETE"])
|
|
@login_required
|
|
def delete_article_route(article_id):
|
|
try:
|
|
ok = storage.delete_article(request.account, article_id)
|
|
if not ok:
|
|
return reply_error("article not found")
|
|
return reply_ok({"deleted": True})
|
|
except PermissionError as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/groups", methods=["GET"])
|
|
@login_required
|
|
def list_groups_route():
|
|
person_id = request.args.get("person_id")
|
|
groups = storage.list_groups(request.account, person_id)
|
|
return reply_ok(groups)
|
|
|
|
@app.route("/groups", methods=["POST"])
|
|
@login_required
|
|
def create_group_route():
|
|
person_id = request.form.get("person_id") or request.json.get("person_id")
|
|
name = request.form.get("name") or request.json.get("name") or ""
|
|
if not person_id:
|
|
return reply_error("person_id required")
|
|
try:
|
|
group = storage.create_group(request.account, person_id, name)
|
|
return reply_ok(group)
|
|
except PermissionError as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/groups/<group_id>")
|
|
@login_required
|
|
def get_group_route(group_id):
|
|
group = storage.get_group(group_id)
|
|
if not group:
|
|
return reply_error("group not found")
|
|
if not storage.group_is_visible_to(request.account, group_id):
|
|
return reply_error("group not found")
|
|
return reply_ok(group)
|
|
|
|
@app.route("/groups/<group_id>", methods=["PATCH"])
|
|
@login_required
|
|
def update_group_route(group_id):
|
|
name = request.form.get("name") or request.json.get("name")
|
|
name = name if name is not None else None
|
|
try:
|
|
group = storage.update_group(request.account, group_id, name)
|
|
if not group:
|
|
return reply_error("group not found")
|
|
return reply_ok(group)
|
|
except PermissionError as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/groups/<group_id>", methods=["DELETE"])
|
|
@login_required
|
|
def delete_group_route(group_id):
|
|
try:
|
|
ok = storage.delete_group(request.account, group_id)
|
|
if not ok:
|
|
return reply_error("group not found")
|
|
return reply_ok({"deleted": True})
|
|
except PermissionError as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/groups/<group_id>/members/<person_id>", methods=["POST"])
|
|
@login_required
|
|
def add_member_route(group_id, person_id):
|
|
try:
|
|
group = storage.add_group_member(request.account, group_id, person_id)
|
|
return reply_ok(group)
|
|
except (ValueError, PermissionError) as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/groups/<group_id>/members/<person_id>", methods=["DELETE"])
|
|
@login_required
|
|
def remove_member_route(group_id, person_id):
|
|
try:
|
|
group = storage.remove_group_member(request.account, group_id, person_id)
|
|
return reply_ok(group)
|
|
except PermissionError as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/rights", methods=["GET"])
|
|
@login_required
|
|
def list_rights_route():
|
|
account_id = request.args.get("account_id")
|
|
try:
|
|
rights = storage.list_rights(request.account, account_id)
|
|
except PermissionError as e:
|
|
return reply_error(str(e))
|
|
return reply_ok(rights)
|
|
|
|
@app.route("/rights/grant", methods=["POST"])
|
|
@login_required
|
|
def grant_right_route():
|
|
target_account_id = request.form.get("account_id") or request.json.get("account_id")
|
|
right_name = request.form.get("right") or request.json.get("right")
|
|
if not target_account_id or not right_name:
|
|
return reply_error("account_id and right required")
|
|
try:
|
|
storage.grant_right(request.account, target_account_id, right_name)
|
|
return reply_ok({"granted": right_name})
|
|
except (ValueError, PermissionError) as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/rights/revoke", methods=["POST"])
|
|
@login_required
|
|
def revoke_right_route():
|
|
target_account_id = request.form.get("account_id") or request.json.get("account_id")
|
|
right_name = request.form.get("right") or request.json.get("right")
|
|
if not target_account_id or not right_name:
|
|
return reply_error("account_id and right required")
|
|
try:
|
|
storage.revoke_right(request.account, target_account_id, right_name)
|
|
return reply_ok({"revoked": right_name})
|
|
except PermissionError as e:
|
|
return reply_error(str(e))
|
|
|
|
@app.route("/api/types")
|
|
def article_types():
|
|
return reply_ok({
|
|
"article_types": storage.ARTICLE_TYPES,
|
|
"rights": storage.RIGHTS,
|
|
"article_type_labels": ARTICLE_TYPE_LABELS,
|
|
})
|
|
|
|
return app
|
|
|
|
|
|
def render_account_create_page(error=None):
|
|
return render_template_string(
|
|
"""
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Who² - Create account</title>
|
|
<link rel="stylesheet" href="/static/style.css" />
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>Create account</h1>
|
|
<form id="create-account-form" method="post" action="/account/create">
|
|
<label>email
|
|
<input name="email" type="email" autocomplete="username" required />
|
|
</label>
|
|
<label>password
|
|
<input name="password" type="password" autocomplete="new-password" required />
|
|
</label>
|
|
<button type="submit">Create account</button>
|
|
</form>
|
|
<p class="hint">Already have an account? <a href="/">Log in</a></p>
|
|
<pre id="create-msg" class="msg{% if not error %} hidden{% endif %}">{{ error }}</pre>
|
|
</div>
|
|
<script src="/static/crypto.js"></script>
|
|
<script src="/static/ui.js"></script>
|
|
<script>
|
|
(function () {
|
|
var form = document.getElementById("create-account-form");
|
|
var msg = document.getElementById("create-msg");
|
|
form.addEventListener("submit", function (e) {
|
|
e.preventDefault();
|
|
msg.textContent = "";
|
|
msg.classList.add("hidden");
|
|
var email = form.email.value.trim();
|
|
var password = form.password.value;
|
|
if (!email || !password) {
|
|
msg.textContent = "email and password required";
|
|
msg.style.color = "#dc2626";
|
|
msg.classList.remove("hidden");
|
|
return;
|
|
}
|
|
fetch("/account/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: email, password: password })
|
|
})
|
|
.then(function (r) { return r.json().then(function (data) { if (!r.ok) throw new Error(data.error || "account creation failed"); return data; }); })
|
|
.then(function (data) {
|
|
msg.textContent = "account created: " + (data.email || "");
|
|
msg.style.color = "#047857";
|
|
msg.classList.remove("hidden");
|
|
window.setTimeout(function () { window.location.href = "/"; }, 700);
|
|
})
|
|
.catch(function (err) {
|
|
msg.textContent = err.message || "account creation failed";
|
|
msg.style.color = "#dc2626";
|
|
msg.classList.remove("hidden");
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
""",
|
|
error=error or "",
|
|
)
|
|
|
|
|
|
def render_login_page(error=None):
|
|
return render_template_string(
|
|
"""
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Who² - Login</title>
|
|
<link rel="stylesheet" href="/static/style.css" />
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>Who²</h1>
|
|
<form id="login-form" method="post" action="/login">
|
|
<label>email
|
|
<input name="email" type="email" autocomplete="username" required />
|
|
</label>
|
|
<label>password
|
|
<input name="password" type="password" autocomplete="current-password" required />
|
|
</label>
|
|
<button type="submit">Log in</button>
|
|
</form>
|
|
<p class="hint">No account yet? <a href="/account/create">Create one</a></p>
|
|
<pre id="login-msg" class="msg{% if not error %} hidden{% endif %}">{{ error }}</pre>
|
|
</div>
|
|
<script src="/static/crypto.js"></script>
|
|
<script src="/static/vendor/noble-hashes/blake2.js"></script>
|
|
<script src="/static/vendor/noble-hashes/argon2.js"></script>
|
|
<script src="/static/vendor/noble-ciphers/chacha.js"></script>
|
|
<script src="/static/vendor/noble-ciphers/_arx.js"></script>
|
|
<script src="/static/vendor/noble-ciphers/_poly1305.js"></script>
|
|
<script src="/static/vendor/noble-ciphers/utils.js"></script>
|
|
<script src="/static/vendor/noble-hashes/utils.js"></script>
|
|
<script src="/static/vendor/noble-hashes/_u64.js"></script>
|
|
<script src="/static/vendor/noble-hashes/_md.js"></script>
|
|
<script src="/static/vendor/noble-hashes/_blake.js"></script>
|
|
<script src="/static/ui.js"></script>
|
|
</body>
|
|
</html>
|
|
""",
|
|
error=error or "",
|
|
)
|
|
|
|
|
|
def render_index(account=None):
|
|
is_admin = storage.is_admin(account)
|
|
return render_template_string(
|
|
"""
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Who²</title>
|
|
<link rel="stylesheet" href="/static/style.css" />
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Who²</h1>
|
|
<div class="account-bar">
|
|
<span id="user-email"></span>
|
|
<span id="user-role" class="badge"></span>
|
|
<a href="/logout" class="btn-small">Log out</a>
|
|
</div>
|
|
</header>
|
|
<main>
|
|
<section class="panel" id="myperson-panel">
|
|
<h2>My person</h2>
|
|
<div id="myperson" class="meta"></div>
|
|
</section>
|
|
|
|
<section class="panel" id="note-panel">
|
|
<h2>{% if is_admin %}Add article{% else %}Add note{% endif %}</h2>
|
|
<form id="note-form">
|
|
{% if is_admin %}
|
|
<label>person
|
|
<select id="note-person" name="person_id" required></select>
|
|
</label>
|
|
<label>type
|
|
<select id="note-type" name="article_type" required></select>
|
|
</label>
|
|
{% endif %}
|
|
<label>title
|
|
<input id="note-title" name="title" />
|
|
</label>
|
|
<label>content
|
|
<textarea id="note-content" name="content" required></textarea>
|
|
</label>
|
|
{% if is_admin %}
|
|
<label>visibility
|
|
<select id="note-visibility" name="visibility">
|
|
<option value="private">private</option>
|
|
<option value="group">group</option>
|
|
<option value="public">public</option>
|
|
</select>
|
|
</label>
|
|
{% endif %}
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
<p class="hint" id="note-hint"></p>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Articles</h2>
|
|
<ul id="articles-list"></ul>
|
|
</section>
|
|
|
|
<section class="panel" id="groups-panel">
|
|
<h2>My groups</h2>
|
|
<form id="group-form">
|
|
<label>name
|
|
<input id="group-name" name="name" required />
|
|
</label>
|
|
<button type="submit">Create group</button>
|
|
</form>
|
|
<ul id="groups-list"></ul>
|
|
<form id="invite-form">
|
|
<label>group
|
|
<select id="invite-group" name="group_id" required></select>
|
|
</label>
|
|
<label>person
|
|
<select id="invite-person" name="person_id" required></select>
|
|
</label>
|
|
<button type="submit">Invite person</button>
|
|
<button type="button" id="join-self" class="btn-ghost">Add me</button>
|
|
</form>
|
|
</section>
|
|
|
|
{% if is_admin %}
|
|
<section class="panel admin" id="admin-person-panel">
|
|
<h2>Admin · create person</h2>
|
|
<form id="admin-person-form">
|
|
<label>name
|
|
<input id="admin-person-name" name="name" required />
|
|
</label>
|
|
<label>email
|
|
<input id="admin-person-email" name="email" type="email" />
|
|
</label>
|
|
<label>password (only for a new account)
|
|
<input id="admin-person-password" name="password" type="password" />
|
|
</label>
|
|
<button type="submit">Create person</button>
|
|
</form>
|
|
<ul id="admin-persons-list"></ul>
|
|
</section>
|
|
|
|
<section class="panel admin" id="admin-email-panel">
|
|
<h2>Admin · person emails</h2>
|
|
<form id="attach-form">
|
|
<label>person
|
|
<select id="attach-person" name="person_id" required></select>
|
|
</label>
|
|
<label>email
|
|
<input id="attach-email" name="email" type="email" required />
|
|
</label>
|
|
<button type="submit">Attach</button>
|
|
<button type="button" id="detach-email" class="btn-ghost">Detach</button>
|
|
</form>
|
|
<p class="hint">The email needs an account; create one above by giving a password.</p>
|
|
</section>
|
|
|
|
<section class="panel admin" id="admin-rights-panel">
|
|
<h2>Admin · accounts & rights</h2>
|
|
<form id="right-form">
|
|
<label>account
|
|
<select id="right-account" name="account_id" required></select>
|
|
</label>
|
|
<label>right
|
|
<select id="right-right" name="right" required></select>
|
|
</label>
|
|
<button type="submit">Grant</button>
|
|
<button type="button" id="revoke-right" class="btn-ghost">Revoke selected</button>
|
|
</form>
|
|
<ul id="rights-list"></ul>
|
|
</section>
|
|
{% endif %}
|
|
</main>
|
|
<pre id="app-msg" class="msg hidden"></pre>
|
|
|
|
<script src="/static/crypto.js"></script>
|
|
<script src="/static/vendor/noble-hashes/blake2.js"></script>
|
|
<script src="/static/vendor/noble-hashes/argon2.js"></script>
|
|
<script src="/static/vendor/noble-ciphers/chacha.js"></script>
|
|
<script src="/static/vendor/noble-ciphers/_arx.js"></script>
|
|
<script src="/static/vendor/noble-ciphers/_poly1305.js"></script>
|
|
<script src="/static/vendor/noble-ciphers/utils.js"></script>
|
|
<script src="/static/vendor/noble-hashes/utils.js"></script>
|
|
<script src="/static/vendor/noble-hashes/_u64.js"></script>
|
|
<script src="/static/vendor/noble-hashes/_md.js"></script>
|
|
<script src="/static/vendor/noble-hashes/_blake.js"></script>
|
|
<script src="/static/ui.js"></script>
|
|
</body>
|
|
</html>
|
|
""",
|
|
is_admin=is_admin,
|
|
)
|
|
|
|
|
|
def reply_ok(body):
|
|
resp = jsonify(body)
|
|
resp.status_code = 200
|
|
return resp
|
|
|
|
|
|
def reply_error(message):
|
|
resp = jsonify({"error": message})
|
|
resp.status_code = 400
|
|
return resp
|