add /accounts/ (get, post) routes

Slight markup changes, same style overall and same
form parameters as the PHP implementation.

In addition, we've disabled the "left" and "right"
navigation buttons when we're at the border of the
table.

CSS Changes:

- Added similar styling to submit `<buttons>` that submit `<input>` had.
- Added .results tr td[align="{left,right}"] styling to align
  the result table's `More -->` button to the right of the table.

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2021-06-24 22:39:16 -07:00
parent bdc913d088
commit 021a1c8fb6
7 changed files with 760 additions and 7 deletions

View file

@ -168,6 +168,15 @@ class User(Base):
aurweb.models.account_type.TRUSTED_USER_AND_DEV_ID
}
def can_edit_user(self, user):
""" Can this account record edit the target user? It must either
be the target user or a user with enough permissions to do so.
:param user: Target user
:return: Boolean indicating whether this instance can edit `user`
"""
return self == user or self.is_trusted_user() or self.is_developer()
def __repr__(self):
return "<User(ID='%s', AccountType='%s', Username='%s')>" % (
self.ID, str(self.AccountType), self.Username)

View file

@ -12,17 +12,18 @@ from sqlalchemy import and_, func, or_
import aurweb.config
from aurweb import db, l10n, time, util
from aurweb.auth import auth_required
from aurweb.auth import account_type_required, auth_required
from aurweb.captcha import get_captcha_answer, get_captcha_salts, get_captcha_token
from aurweb.l10n import get_translator_for_request
from aurweb.models.accepted_term import AcceptedTerm
from aurweb.models.account_type import AccountType
from aurweb.models.account_type import (DEVELOPER, DEVELOPER_ID, TRUSTED_USER, TRUSTED_USER_AND_DEV, TRUSTED_USER_AND_DEV_ID,
TRUSTED_USER_ID, USER_ID, AccountType)
from aurweb.models.ban import Ban
from aurweb.models.ssh_pub_key import SSHPubKey, get_fingerprint
from aurweb.models.term import Term
from aurweb.models.user import User
from aurweb.scripts.notify import ResetKeyNotification
from aurweb.templates import make_variable_context, render_template
from aurweb.templates import make_context, make_variable_context, render_template
router = APIRouter()
logger = logging.getLogger(__name__)
@ -591,6 +592,91 @@ async def account(request: Request, username: str):
return render_template(request, "account/show.html", context)
@router.get("/accounts/")
@auth_required(True)
@account_type_required({TRUSTED_USER, DEVELOPER, TRUSTED_USER_AND_DEV})
async def accounts(request: Request):
context = make_context(request, "Accounts")
return render_template(request, "account/search.html", context)
@router.post("/accounts/")
@auth_required(True)
@account_type_required({TRUSTED_USER, DEVELOPER, TRUSTED_USER_AND_DEV})
async def accounts_post(request: Request,
O: int = Form(default=0), # Offset
SB: str = Form(default=str()), # Search By
U: str = Form(default=str()), # Username
T: str = Form(default=str()), # Account Type
S: bool = Form(default=False), # Suspended
E: str = Form(default=str()), # Email
R: str = Form(default=str()), # Real Name
I: str = Form(default=str()), # IRC Nick
K: str = Form(default=str())): # PGP Key
context = await make_variable_context(request, "Accounts")
context["pp"] = pp = 50 # Hits per page.
offset = max(O, 0) # Minimize offset at 0.
context["offset"] = offset # Offset.
context["params"] = dict(await request.form())
if "O" in context["params"]:
context["params"].pop("O")
# Setup order by criteria based on SB.
order_by_columns = {
"t": (AccountType.ID.asc(), User.Username.asc()),
"r": (User.RealName.asc(), AccountType.ID.asc()),
"i": (User.IRCNick.asc(), AccountType.ID.asc()),
}
default_order = (User.Username.asc(), AccountType.ID.asc())
order_by = order_by_columns.get(SB, default_order)
# Convert parameter T to an AccountType ID.
account_types = {
"u": USER_ID,
"t": TRUSTED_USER_ID,
"d": DEVELOPER_ID,
"td": TRUSTED_USER_AND_DEV_ID
}
account_type_id = account_types.get(T, None)
# Get a query handle to users, populate the total user
# count into a jinja2 context variable.
query = db.query(User).join(AccountType)
context["total_users"] = query.count()
# Populate this list with any additional statements to
# be ANDed together.
statements = []
if account_type_id is not None:
statements.append(AccountType.ID == account_type_id)
if U:
statements.append(User.Username.like(f"%{U}%"))
if S:
statements.append(User.Suspended == S)
if E:
statements.append(User.Email.like(f"%{E}%"))
if R:
statements.append(User.RealName.like(f"%{R}%"))
if I:
statements.append(User.IRCNick.like(f"%{I}%"))
if K:
statements.append(User.PGPKey.like(f"%{K}%"))
# Filter the query by combining all statements added above into
# an AND statement, unless there's just one statement, which
# we pass on to filter() as args.
if statements:
query = query.filter(and_(*statements))
# Finally, order and truncate our users for the current page.
users = query.order_by(*order_by).limit(pp).offset(offset)
context["users"] = users
return render_template(request, "account/index.html", context)
def render_terms_of_service(request: Request,
context: dict,
terms: typing.Iterable):

View file

@ -0,0 +1,13 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
<div class="box">
<h2>{{ "Accounts" | tr }}</h2>
{% if not users %}
{{ "No results matched your search criteria." | tr }}
{% else %}
{% include "partials/account/results.html" %}
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,69 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
<div class="box">
<h2>{{ "Accounts" | tr }}</h2>
{{ "Use this form to search existing accounts." | tr }}
<br />
<br />
<form class="account-search-form" action="/accounts/" method="post">
<fieldset>
<p>
<label for="id_username">{{ "Username" | tr }}:</label>
<input type="text" size="30" maxlength="64" name="U"
id="id_username" />
</p>
<p>
<label for="id_type">{{ "Account Type" | tr }}:</label>
<select name="T" id="id_type">
<option value="">{{ "Any type" | tr }}</option>
<option value="u">{{ "Normal user" | tr }}</option>
<option value="t">{{ "Trusted user" | tr }}</option>
<option value="d">{{ "Developer" | tr }}</option>
<option value="td">{{ "Trusted User & Developer" | tr }}</option>
</select>
</p>
<p>
<label for="id_suspended">{{ "Account Suspended" | tr }}:</label>
<input type="checkbox" name="S" id="id_suspended" />
</p>
<p>
<label for="id_email">{{ "Email Address" | tr }}:</label>
<input type="text" size="30" maxlength="64" name="E"
id="id_email" />
</p>
<p>
<label for="id_realname">{{ "Real Name" | tr }}:</label>
<input type="text" size="30" maxlength="32" name="R"
id="id_realname" />
</p>
<p>
<label for="id_irc">{{ "IRC Nick" | tr }}:</label>
<input type="text" size="30" maxlength="32" name="I"
id="id_irc" />
</p>
<p>
<label for="id_sortby">{{ "Sort by" | tr }}:</label>
<select name="SB" id="id_sortby">
<option value="u">{{ "Username" | tr }}</option>
<option value="t">{{ "Account Type" | tr }}</option>
<option value="r">{{ "Real Name" | tr }}</option>
<option value="i">{{ "IRC Nick" | tr }}</option>
</select>
</p>
<p>
<label></label>
<button type="submit" class="button">
{{ "Search" | tr }}
</button>
&nbsp;
<button type="reset" class="button">
{{ "Reset" | tr }}
</button>
</p>
</fieldset>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,82 @@
<table class="results users">
<thead>
<tr>
<th>{{ "Username" | tr }}</th>
<th>{{ "Type" | tr }}</th>
<th>{{ "Status" | tr }}</th>
<th>{{ "Real Name" | tr }}</th>
<th>{{ "IRC Nick" | tr }}</th>
<th>{{ "PGP Key Fingerprint" | tr }}</th>
<th>{{ "Edit Account" | tr }}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<a href="/packages/?K={{ user.Username }}&amp;SeB=m">
{{ user.Username }}
</a>
</td>
<td>{{ user.AccountType.AccountType }}</td>
<td>{{ "Suspended" if user.Suspended else "Active" }}</td>
<td>{{ user.RealName | e }}</td>
<td>{{ user.IRCNick | e }}</td>
<td>{{ user.PGPKey or '' | e }}</td>
<td>
{% if request.user.can_edit_user(user) %}
<a href="/account/{{ user.Username }}/edit">
{{ "Edit" | tr }}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="results">
<tr>
<td align="left">
<form action="/accounts/" method="post">
<fieldset>
<input type="hidden" name="O"
value="{{ offset - pp }}" />
{% for k, v in params.items() %}
<input type="hidden" name="{{ k }}"
value="{{ v }}" />
{% endfor %}
<button type="submit" class="button page-prev"
{% if offset <= 0 %}
disabled
{% endif %}
>
&lt;-- {{ "Less" | tr }}
</button>
</fieldset>
</form>
</td>
<td align="right">
<form action="/accounts/" method="post">
<fieldset>
<input type="hidden" name="O"
value="{{ offset + pp }}" />
{% for k, v in params.items() %}
<input type="hidden" name="{{ k }}"
value="{{ v }}" />
{% endfor %}
<button type="submit" class="button page-next"
{% if offset + pp >= total_users %}
disabled
{% endif %}
>
{{ "More" | tr }}--&gt;
</button>
</fieldset>
</form>
</td>
</tr>
</table>

View file

@ -1,3 +1,4 @@
import logging
import re
import tempfile
@ -14,7 +15,7 @@ from aurweb import captcha
from aurweb.asgi import app
from aurweb.db import commit, create, query
from aurweb.models.accepted_term import AcceptedTerm
from aurweb.models.account_type import AccountType
from aurweb.models.account_type import DEVELOPER_ID, TRUSTED_USER_AND_DEV_ID, TRUSTED_USER_ID, AccountType
from aurweb.models.ban import Ban
from aurweb.models.session import Session
from aurweb.models.ssh_pub_key import SSHPubKey, get_fingerprint
@ -31,6 +32,8 @@ TEST_EMAIL = "test@example.org"
client = TestClient(app)
user = None
logger = logging.getLogger(__name__)
def make_ssh_pubkey():
# Create a public key with ssh-keygen (this adds ssh-keygen as a
@ -55,8 +58,8 @@ def setup():
account_type = query(AccountType,
AccountType.AccountType == "User").first()
user = create(User, Username=TEST_USERNAME, Email=TEST_EMAIL,
RealName="Test User", Passwd="testPassword",
AccountType=account_type)
RealName="Test UserZ", Passwd="testPassword",
IRCNick="testZ", AccountType=account_type)
yield user
@ -65,6 +68,14 @@ def setup():
setup_test_db("Terms", "AcceptedTerms")
@pytest.fixture
def tu_user():
user.AccountType = query(AccountType,
AccountType.ID == TRUSTED_USER_AND_DEV_ID).first()
commit()
yield user
def test_get_passreset_authed_redirects():
sid = user.login(Request(), "testPassword")
assert sid is not None
@ -929,6 +940,479 @@ def test_get_account_unauthenticated():
assert "You must log in to view user information." in content
def test_get_accounts(tu_user):
""" Test that we can GET request /accounts/ and receive
a form which can be used to POST /accounts/. """
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
with client as request:
response = request.get("/accounts/", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
parser = lxml.etree.HTMLParser()
root = lxml.etree.fromstring(response.text, parser=parser)
# Get the form.
form = root.xpath('//form[contains(@class, "account-search-form")]')
# Make sure there's only one form and it goes where it should.
assert len(form) == 1
form = next(iter(form))
assert form.attrib.get("method") == "post"
assert form.attrib.get("action") == "/accounts/"
def field(element):
""" Return the given element string as a valid
selector in the form. """
return f"./fieldset/p/{element}"
username = form.xpath(field('input[@id="id_username"]'))
assert bool(username)
account_type = form.xpath(field('select[@id="id_type"]'))
assert bool(account_type)
suspended = form.xpath(field('input[@id="id_suspended"]'))
assert bool(suspended)
email = form.xpath(field('input[@id="id_email"]'))
assert bool(email)
realname = form.xpath(field('input[@id="id_realname"]'))
assert bool(realname)
irc = form.xpath(field('input[@id="id_irc"]'))
assert bool(irc)
sortby = form.xpath(field('select[@id="id_sortby"]'))
assert bool(sortby)
def parse_root(html):
parser = lxml.etree.HTMLParser()
return lxml.etree.fromstring(html, parser=parser)
def get_rows(html):
root = parse_root(html)
return root.xpath('//table[contains(@class, "users")]/tbody/tr')
def test_post_accounts(tu_user):
# Set a PGPKey.
user.PGPKey = "5F18B20346188419750745D7335F2CB41F253D30"
# Create a few more users.
users = [user]
for i in range(10):
_user = create(User, Username=f"test_{i}",
Email=f"test_{i}@example.org",
RealName=f"Test #{i}",
Passwd="testPassword",
IRCNick=f"test_#{i}",
autocommit=False)
users.append(_user)
# Commit everything to the database.
commit()
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
with client as request:
response = request.post("/accounts/", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 11
# Simulate default ascending ORDER_BY.
sorted_users = sorted(users, key=lambda u: u.Username)
for i, _user in enumerate(sorted_users):
columns = rows[i].xpath("./td")
assert len(columns) == 7
username, atype, suspended, real_name, \
irc_nick, pgp_key, edit = columns
username = next(iter(username.xpath("./a")))
assert username.text.strip() == _user.Username
assert atype.text.strip() == str(_user.AccountType)
assert suspended.text.strip() == "Active"
assert real_name.text.strip() == _user.RealName
assert irc_nick.text == _user.IRCNick
assert pgp_key.text == (_user.PGPKey or None)
edit = edit.xpath("./a")
if user.can_edit_user(_user):
edit = next(iter(edit))
assert edit.text.strip() == "Edit"
else:
assert not edit
logger.debug('Checked user row {"id": %s, "username": "%s"}.'
% (_user.ID, _user.Username))
def test_post_accounts_username(tu_user):
# Test the U parameter path.
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"U": user.Username})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
row = next(iter(rows))
username, type, status, realname, irc, pgp_key, edit = row
username = next(iter(username.xpath("./a")))
assert username.text.strip() == user.Username
def test_post_accounts_account_type(tu_user):
# Check the different account type options.
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
# Make a user with the "User" role here so we can
# test the `u` parameter.
account_type = query(AccountType,
AccountType.AccountType == "User").first()
create(User, Username="test_2",
Email="test_2@example.org",
RealName="Test User 2",
Passwd="testPassword",
AccountType=account_type)
# Expect no entries; we marked our only user as a User type.
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"T": "t"})
assert response.status_code == int(HTTPStatus.OK)
assert len(get_rows(response.text)) == 0
# So, let's also ensure that specifying "u" returns our user.
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"T": "u"})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
row = next(iter(rows))
username, type, status, realname, irc, pgp_key, edit = row
assert type.text.strip() == "User"
# Set our only user to a Trusted User.
user.AccountType = query(AccountType,
AccountType.ID == TRUSTED_USER_ID).first()
commit()
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"T": "t"})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
row = next(iter(rows))
username, type, status, realname, irc, pgp_key, edit = row
assert type.text.strip() == "Trusted User"
user.AccountType = query(AccountType,
AccountType.ID == DEVELOPER_ID).first()
commit()
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"T": "d"})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
row = next(iter(rows))
username, type, status, realname, irc, pgp_key, edit = row
assert type.text.strip() == "Developer"
user.AccountType = query(AccountType,
AccountType.ID == TRUSTED_USER_AND_DEV_ID
).first()
commit()
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"T": "td"})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
row = next(iter(rows))
username, type, status, realname, irc, pgp_key, edit = row
assert type.text.strip() == "Trusted User & Developer"
def test_post_accounts_status(tu_user):
# Test the functionality of Suspended.
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
with client as request:
response = request.post("/accounts/", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
row = next(iter(rows))
username, type, status, realname, irc, pgp_key, edit = row
assert status.text.strip() == "Active"
user.Suspended = True
commit()
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"S": True})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
row = next(iter(rows))
username, type, status, realname, irc, pgp_key, edit = row
assert status.text.strip() == "Suspended"
def test_post_accounts_email(tu_user):
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
# Search via email.
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"E": user.Email})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
def test_post_accounts_realname(tu_user):
# Test the R parameter path.
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"R": user.RealName})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
def test_post_accounts_irc(tu_user):
# Test the I parameter path.
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"I": user.IRCNick})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
def test_post_accounts_sortby(tu_user):
# Create a second user so we can compare sorts.
account_type = query(AccountType,
AccountType.ID == DEVELOPER_ID).first()
create(User, Username="test2",
Email="test2@example.org",
RealName="Test User 2",
Passwd="testPassword",
IRCNick="test2",
AccountType=account_type)
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
# Show that "u" is the default search order, by username.
with client as request:
response = request.post("/accounts/", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 2
first_rows = rows
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"SB": "u"})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 2
def compare_text_values(column, lhs, rhs):
return [row[column].text.strip() for row in lhs] \
== [row[column].text.strip() for row in rhs]
# Test the username rows are ordered the same.
assert compare_text_values(0, first_rows, rows) is True
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"SB": "i"})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 2
# Test the rows are reversed when ordering by IRCNick.
assert compare_text_values(4, first_rows, reversed(rows)) is True
# Sort by "i" -> RealName.
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"SB": "r"})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 2
# Test the rows are reversed when ordering by RealName.
assert compare_text_values(4, first_rows, reversed(rows)) is True
user.AccountType = query(AccountType,
AccountType.ID == TRUSTED_USER_AND_DEV_ID).first()
commit()
# Fetch first_rows again with our new AccountType ordering.
with client as request:
response = request.post("/accounts/", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 2
first_rows = rows
# Sort by "t" -> AccountType.
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"SB": "t"})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 2
# Test that rows again got reversed.
assert compare_text_values(1, first_rows, reversed(rows))
def test_post_accounts_pgp_key(tu_user):
user.PGPKey = "5F18B20346188419750745D7335F2CB41F253D30"
commit()
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
# Search via PGPKey.
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"K": user.PGPKey})
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 1
def test_post_accounts_paged(tu_user):
# Create 150 users.
users = [user]
account_type = query(AccountType,
AccountType.AccountType == "User").first()
for i in range(150):
_user = create(User, Username=f"test_#{i}",
Email=f"test_#{i}@example.org",
RealName=f"Test User #{i}",
Passwd="testPassword",
AccountType=account_type,
autocommit=False)
users.append(_user)
commit()
sid = user.login(Request(), "testPassword")
cookies = {"AURSID": sid}
with client as request:
response = request.post("/accounts/", cookies=cookies)
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 50 # `pp`, or hits per page is defined at 50.
# Sort users in ascending default sort by order.
sorted_users = sorted(users, key=lambda u: u.Username)
# Get the first fifty sorted users and assert that's what
# we got in the first search result page.
first_fifty = sorted_users[:50]
for i, _user in enumerate(first_fifty):
row = rows[i]
username = row[0].xpath("./a")[0] # First column
assert username.text.strip() == _user.Username
root = parse_root(response.text)
page_prev = root.xpath('//button[contains(@class, "page-prev")]')[0]
page_next = root.xpath('//button[contains(@class, "page-next")]')[0]
assert page_prev.attrib["disabled"] == "disabled"
assert "disabled" not in page_next.attrib
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"O": 50}) # +50 offset.
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 50
second_fifty = sorted_users[50:100]
for i, _user in enumerate(second_fifty):
row = rows[i]
username = row[0].xpath("./a")[0] # First column
assert username.text.strip() == _user.Username
with client as request:
response = request.post("/accounts/", cookies=cookies,
data={"O": 101}) # Last page.
assert response.status_code == int(HTTPStatus.OK)
rows = get_rows(response.text)
assert len(rows) == 50
root = parse_root(response.text)
page_prev = root.xpath('//button[contains(@class, "page-prev")]')[0]
page_next = root.xpath('//button[contains(@class, "page-next")]')[0]
assert "disabled" not in page_prev.attrib
assert page_next.attrib["disabled"] == "disabled"
def test_get_terms_of_service():
term = create(Term, Description="Test term.",
URL="http://localhost", Revision=1)

View file

@ -209,6 +209,16 @@ label.confirmation,
margin: .33em 0 1em;
}
button[type="submit"] {
button[type="submit"],
button[type="reset"] {
padding: 0 0.6em;
}
.results tr td[align="left"] fieldset {
text-align: left;
}
.results tr td[align="right"] fieldset {
text-align: right;
}