Compare commits
12 commits
main
...
feat/admin
Author | SHA1 | Date | |
---|---|---|---|
e8634eeff1 | |||
08aa16f36b | |||
1ebe51124a | |||
ea202cf393 | |||
55a2ee1bfb | |||
9ae95b4e93 | |||
5722b49c37 | |||
136c6f1438 | |||
b4ea4e3d1a | |||
897ab437c1 | |||
9d674863df | |||
b1fc5edc6e |
13 changed files with 391 additions and 26 deletions
15
.sqlx/query-f5e08fe2f3c38631022a83e0038257905b21581564499b5d44d3c7448fb3fb50.json
generated
Normal file
15
.sqlx/query-f5e08fe2f3c38631022a83e0038257905b21581564499b5d44d3c7448fb3fb50.json
generated
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "update doll_profiles set bound_to_id = $1 where id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f5e08fe2f3c38631022a83e0038257905b21581564499b5d44d3c7448fb3fb50"
|
||||
}
|
|
@ -66,11 +66,11 @@ impl<'a> FromRequest<'a> for User {
|
|||
let cookies = req.cookies();
|
||||
|
||||
if let Some(id) = check_login(&cookies) {
|
||||
let db = DollTagsDb::from_request(req)
|
||||
let mut db = DollTagsDb::from_request(req)
|
||||
.await
|
||||
.expect("User::from_request cannot get DB connection");
|
||||
|
||||
match user::get_by_id(db, &id).await {
|
||||
match user::get_by_id(&mut *db, &id).await {
|
||||
Err(err) => {
|
||||
error!("User::from_request internal error: {:?}", err);
|
||||
Outcome::Error((Status::InternalServerError, SessionInternalFailure()))
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
use super::schema::{ServiceStatus, TrxHook};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::schema::{DbHook, ServiceStatus, TrxHook};
|
||||
|
||||
/// Aggregates info on the current service status, incl. accounts and tags
|
||||
pub async fn get_service_status(trx: &mut TrxHook<'_>) -> sqlx::Result<ServiceStatus> {
|
||||
|
@ -19,3 +21,15 @@ pub async fn get_service_status(trx: &mut TrxHook<'_>) -> sqlx::Result<ServiceSt
|
|||
active_tags_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handover_tag(db: &mut DbHook, tag_id: i32, target_user_id: &Uuid) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
"update doll_profiles set bound_to_id = $1 where id = $2",
|
||||
target_user_id,
|
||||
tag_id
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -2,22 +2,22 @@ use uuid::Uuid;
|
|||
|
||||
use crate::db::schema::User;
|
||||
|
||||
use super::schema::{DbHook, DollTagsDb, TrxHook};
|
||||
use super::schema::{DbHook, TrxHook};
|
||||
|
||||
pub async fn get(mut db: DollTagsDb, username: &str) -> sqlx::Result<Option<User>> {
|
||||
pub async fn get(db: &mut DbHook, username: &str) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(User, "select * from users where username = $1", username)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_by_id(mut db: DollTagsDb, id: &Uuid) -> sqlx::Result<Option<User>> {
|
||||
pub async fn get_by_id(db: &mut DbHook, id: &Uuid) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(User, "select * from users where id = $1", id)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
mut db: DollTagsDb,
|
||||
db: &mut DbHook,
|
||||
username: &str,
|
||||
hashed_password: &str,
|
||||
email: Option<&str>,
|
||||
|
|
33
src/ids.rs
33
src/ids.rs
|
@ -1,5 +1,6 @@
|
|||
use rand::{distributions::Uniform, prelude::Distribution, thread_rng};
|
||||
use regex::Regex;
|
||||
use rocket::{form, request::FromParam};
|
||||
|
||||
use crate::db::{doll, schema::DollTagsDb};
|
||||
|
||||
|
@ -25,8 +26,40 @@ pub fn id_public_to_db(id: &str) -> Option<i32> {
|
|||
None
|
||||
}
|
||||
}
|
||||
/// TODO: Check if used anywhere else than template rendering
|
||||
pub fn id_db_to_public(id: i32) -> String {
|
||||
let first = id / 1000;
|
||||
let second = id % 1000;
|
||||
format!("{:0>3}-{:0>3}", first, second)
|
||||
}
|
||||
|
||||
/// A cleaner way to handle on-the-fly in-URL parameter format validation for IDs
|
||||
/// TODO: check and remove all other usages
|
||||
#[derive(UriDisplayPath)]
|
||||
pub struct PublicId(pub i32);
|
||||
|
||||
impl<'a> FromParam<'a> for PublicId {
|
||||
type Error = &'static str;
|
||||
|
||||
fn from_param(param: &'a str) -> Result<Self, Self::Error> {
|
||||
if let Some(v) = id_public_to_db(param) {
|
||||
Ok(PublicId(v))
|
||||
} else {
|
||||
Err("id not formatted properly")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<i32> for PublicId {
|
||||
fn into(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_id<'v>(id: &str) -> form::Result<'v, ()> {
|
||||
if let None = id_public_to_db(id) {
|
||||
Err(form::Error::validation("id not formatted properly"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
10
src/main.rs
10
src/main.rs
|
@ -84,7 +84,15 @@ fn rocket() -> _ {
|
|||
account::export_data,
|
||||
],
|
||||
)
|
||||
.mount("/admin", routes![admin::index,])
|
||||
.mount(
|
||||
"/admin",
|
||||
routes![
|
||||
admin::index,
|
||||
admin::handle_in_page_forms,
|
||||
admin::show_confirm_tag_handover,
|
||||
admin::handle_tag_handover,
|
||||
],
|
||||
)
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
|
|
23
src/pages.rs
23
src/pages.rs
|
@ -2,6 +2,7 @@ use std::collections::HashMap;
|
|||
|
||||
use rocket::{
|
||||
fairing::Fairing,
|
||||
http::CookieJar,
|
||||
outcome::try_outcome,
|
||||
request::{FromRequest, Outcome},
|
||||
serde::Serialize,
|
||||
|
@ -71,7 +72,7 @@ impl<'r> FromRequest<'r> for CommonTemplateState {
|
|||
/// FakeContext exists to be used as a replacement for Context when reusing a form for creation and edition, to avoid repeating lots of code.
|
||||
///
|
||||
/// Note: i made this custom context thingy because i couldn't find a way to create a real context with the right populated data.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct FakeContext {
|
||||
/// Values are made to simulate the same structure as the real Context
|
||||
|
@ -81,3 +82,23 @@ pub struct FakeContext {
|
|||
/// NOP, used to placehold global errors
|
||||
pub form_errors: Vec<()>,
|
||||
}
|
||||
|
||||
/// writes a toast msg into an encrypted cookie
|
||||
/// (note: this overwrites any existing toast)
|
||||
pub fn write_toast(jar: &CookieJar<'_>, message: String) {
|
||||
jar.add_private(("toast", message))
|
||||
}
|
||||
|
||||
/// gets a toast from the encrypted toast cookie if any is set,
|
||||
/// then removes the cookie
|
||||
pub fn pop_toast(jar: &CookieJar<'_>) -> Option<String> {
|
||||
let toast = jar
|
||||
.get_private("toast")
|
||||
.map(|c| String::from(c.value_trimmed()));
|
||||
|
||||
if toast.is_some() {
|
||||
jar.remove_private("toast");
|
||||
}
|
||||
|
||||
toast
|
||||
}
|
||||
|
|
|
@ -1,26 +1,230 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use rocket::{
|
||||
form::{Context, Contextual, Error, Form},
|
||||
http::CookieJar,
|
||||
response::Redirect,
|
||||
serde::Serialize,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use crate::{
|
||||
auth::session::Admin,
|
||||
db::{admin, schema::DollTagsDb},
|
||||
pages::CommonTemplateState,
|
||||
db::{admin, doll, schema::DollTagsDb, user},
|
||||
ids::{id_public_to_db, PublicId},
|
||||
pages::{pop_toast, write_toast, CommonTemplateState},
|
||||
};
|
||||
|
||||
use super::error_handlers::PageResult;
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(meta: CommonTemplateState, mut db: DollTagsDb, user: Admin) -> PageResult {
|
||||
pub async fn index(
|
||||
meta: CommonTemplateState,
|
||||
mut db: DollTagsDb,
|
||||
user: Admin,
|
||||
jar: &'_ CookieJar<'_>,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
let service_status = admin::get_service_status(&mut trx).await?;
|
||||
trx.commit().await?;
|
||||
|
||||
let toast = pop_toast(jar);
|
||||
|
||||
Ok(Template::render(
|
||||
"admin/index",
|
||||
context! {
|
||||
meta: meta.for_user(&user.0),
|
||||
service_status,
|
||||
previous: context! {
|
||||
tag_handover: Context::default(),
|
||||
},
|
||||
toast,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[derive(Debug, FromFormField, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub enum SelectedForm {
|
||||
TagHandover,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct Forms<'a> {
|
||||
pub form: SelectedForm,
|
||||
pub tag_handover: Contextual<'a, TagHandover<'a>>,
|
||||
}
|
||||
|
||||
/// extracts one of the forms' contexts from the form group,
|
||||
/// otherwise uses the $def value (usually you'll give [`&Context::default()`][`Context::default()`])
|
||||
macro_rules! get_for_form {
|
||||
($def:expr, $curr:expr, $target:pat, $value:expr) => {
|
||||
if matches!($curr, $target) {
|
||||
$value
|
||||
} else {
|
||||
$def
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm, Clone)]
|
||||
pub struct TagHandover<'a> {
|
||||
#[field(validate=crate::ids::validate_id())]
|
||||
pub tag_id: &'a str,
|
||||
#[field(validate=len(..=256))]
|
||||
pub dest_account: &'a str,
|
||||
}
|
||||
|
||||
#[post("/", data = "<form>")]
|
||||
pub async fn handle_in_page_forms(
|
||||
meta: CommonTemplateState,
|
||||
mut db: DollTagsDb,
|
||||
user: Admin,
|
||||
mut form: Form<Forms<'_>>,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
match form.form {
|
||||
SelectedForm::TagHandover => {
|
||||
if let Some(values) = &form.tag_handover.value.clone() {
|
||||
let target_user = user::get(&mut *db, values.dest_account).await?;
|
||||
let user_valid = match target_user {
|
||||
Some(user) => {
|
||||
if !user.enabled {
|
||||
form.tag_handover.context.push_error(
|
||||
Error::validation("this user's account is deactivated")
|
||||
.with_name("tag_handover.dest_account"),
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
None => {
|
||||
form.tag_handover.context.push_error(
|
||||
Error::validation("this user doesn't exist")
|
||||
.with_name("tag_handover.dest_account"),
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let target_tag = doll::get(
|
||||
&mut *db,
|
||||
id_public_to_db(values.tag_id)
|
||||
.expect("is form-validated so should always succeed"),
|
||||
"",
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
if target_tag.is_none() {
|
||||
form.tag_handover.context.push_error(
|
||||
Error::validation("no tag exists with this ID")
|
||||
.with_name("tag_handover.tag_id"),
|
||||
);
|
||||
}
|
||||
|
||||
if user_valid && target_tag.is_some() {
|
||||
let tag = target_tag.unwrap();
|
||||
warn!(
|
||||
"[audit|{}] [{}] beginning tag handover of tag {} to \"{}\" (previous user id: {})",
|
||||
client_ip,
|
||||
user.0.id.to_string(),
|
||||
tag.id,
|
||||
values.dest_account,
|
||||
tag.bound_to_id,
|
||||
);
|
||||
return Ok(Redirect::to(uri!(
|
||||
"/admin",
|
||||
show_confirm_tag_handover(PublicId(tag.id), values.dest_account)
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut trx = db.begin().await?;
|
||||
let service_status = admin::get_service_status(&mut trx).await?;
|
||||
trx.commit().await?;
|
||||
|
||||
let def = Context::default();
|
||||
Ok(Template::render(
|
||||
"admin/index",
|
||||
context! {
|
||||
meta: meta.for_user(&user.0),
|
||||
service_status,
|
||||
previous: context! {
|
||||
tag_handover: get_for_form!(
|
||||
&def,
|
||||
&form.form,
|
||||
SelectedForm::TagHandover,
|
||||
&form.tag_handover.context
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/tag-handover/<id>/<dest_username>")]
|
||||
pub fn show_confirm_tag_handover(
|
||||
meta: CommonTemplateState,
|
||||
user: Admin,
|
||||
id: PublicId,
|
||||
dest_username: &str,
|
||||
) -> Template {
|
||||
let tag_id = id.0;
|
||||
|
||||
Template::render(
|
||||
"admin/confirm_tag_handover",
|
||||
context! {
|
||||
meta: meta.for_user(&user.0),
|
||||
tag_id,
|
||||
dest_username,
|
||||
proceed_url: uri!("/admin", handle_tag_handover(id, dest_username)),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/YES-GIMME/<id>/<dest_username>")]
|
||||
pub async fn handle_tag_handover<'a>(
|
||||
mut db: DollTagsDb,
|
||||
user: Admin,
|
||||
client_ip: IpAddr,
|
||||
jar: &'a CookieJar<'_>,
|
||||
id: PublicId,
|
||||
dest_username: &'a str,
|
||||
) -> PageResult {
|
||||
// note: there is currently no trace of the previous user in this handover code's audit log.
|
||||
let id = id.0;
|
||||
match user::get(&mut *db, dest_username).await? {
|
||||
Some(u) => {
|
||||
admin::handover_tag(&mut *db, id, &u.id).await?;
|
||||
warn!(
|
||||
"[audit|{}] [{}] handed over tag {} to \"{}\"",
|
||||
client_ip,
|
||||
user.0.id.to_string(),
|
||||
id,
|
||||
dest_username
|
||||
);
|
||||
write_toast(
|
||||
jar,
|
||||
format!("tag successfully handed over to \"{}\"", dest_username),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
warn!(
|
||||
"[audit|{}] [{}] tried to hand over tag {} to nonexistent user \"{}\"",
|
||||
client_ip,
|
||||
user.0.id.to_string(),
|
||||
id,
|
||||
dest_username
|
||||
);
|
||||
write_toast(jar, String::from("this username doesn't exist"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Redirect::to("/admin").into())
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ pub fn show_login(
|
|||
|
||||
#[post("/login?<next>", data = "<form>")]
|
||||
pub async fn handle_login(
|
||||
db: DollTagsDb,
|
||||
mut db: DollTagsDb,
|
||||
next: Option<&str>,
|
||||
form: Form<Contextual<'_, AuthForm<'_>>>,
|
||||
cookies: &CookieJar<'_>,
|
||||
|
@ -77,7 +77,7 @@ pub async fn handle_login(
|
|||
|
||||
warn!("[audit|{}] login attempt ({})", client_ip, &values.username);
|
||||
|
||||
let user_in_db = user::get(db, &values.username).await?;
|
||||
let user_in_db = user::get(&mut *db, &values.username).await?;
|
||||
let user = match user_in_db {
|
||||
None => {
|
||||
task::spawn_blocking(move || pw::verify("meow", "$argon2i$v=19$m=65536,t=3,p=1$fJ+f67UGHB+EIjGIDEwbSQ$V/nZPHmdyqHq8fTBTdt3sEmTyr0W7i/F98EIxaaJJt0")).await??;
|
||||
|
@ -151,7 +151,7 @@ fn validate_email<'v>(email: &str) -> form::Result<'v, ()> {
|
|||
|
||||
#[post("/register", data = "<form>")]
|
||||
pub async fn handle_register(
|
||||
db: DollTagsDb,
|
||||
mut db: DollTagsDb,
|
||||
form: Form<Contextual<'_, RegisterForm<'_>>>,
|
||||
cookies: &CookieJar<'_>,
|
||||
maybe_loggedin: Option<User>,
|
||||
|
@ -185,7 +185,7 @@ pub async fn handle_register(
|
|||
let hashed_password = task::spawn_blocking(move || pw::hash(&password)).await??;
|
||||
|
||||
let account_id = user::create(
|
||||
db,
|
||||
&mut *db,
|
||||
values.username,
|
||||
&hashed_password,
|
||||
if values.email.len() != 0 {
|
||||
|
|
|
@ -106,7 +106,7 @@ pub async fn show_edit_tag(
|
|||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct TagForm<'a> {
|
||||
#[field(validate=validate_id())]
|
||||
#[field(validate=crate::ids::validate_id())]
|
||||
pub ident: &'a str,
|
||||
#[field(validate=len(..32))]
|
||||
pub microchip_id: &'a str,
|
||||
|
@ -142,14 +142,6 @@ pub struct TagForm<'a> {
|
|||
pub chassis_color: &'a str,
|
||||
}
|
||||
|
||||
fn validate_id<'v>(id: &str) -> form::Result<'v, ()> {
|
||||
if let None = id_public_to_db(id) {
|
||||
Err(form::Error::validation("id not formatted properly"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_chassis<'v>(a: &str, b: &str, c: &str, field: &str) -> form::Result<'v, ()> {
|
||||
let all_empty = a.len() == 0 && b.len() == 0 && c.len() == 0;
|
||||
let all_full = a.len() != 0
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
<p class="subnav">
|
||||
<a href="/account/new_tag">New tag</a>
|
||||
<a href="/account/settings">Account settings</a>
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin">Admin panel</a>
|
||||
{% endif %}
|
||||
<a href="/logout">Log out</a>
|
||||
</p>
|
||||
</aside>
|
||||
|
@ -39,7 +42,8 @@
|
|||
<summary>show the QR code</summary>
|
||||
|
||||
<picture class="block">
|
||||
<img loading="lazy" src="/account/qr-png/{{profile.id}}" alt="A QrCode containing a direct link to the profile" />
|
||||
<img loading="lazy" src="/account/qr-png/{{profile.id}}"
|
||||
alt="A QrCode containing a direct link to the profile" />
|
||||
</picture>
|
||||
</details>
|
||||
</article>
|
||||
|
|
18
templates/admin/confirm_tag_handover.html.tera
Normal file
18
templates/admin/confirm_tag_handover.html.tera
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% extends "base" %}
|
||||
{% block title %}Confirm tag handover? - {% endblock title %}
|
||||
{% block main %}
|
||||
<section>
|
||||
<h2>Confirm tag handover?</h2>
|
||||
<p>
|
||||
You are about to hand over the tag <code>{{tag_id|pretty_id}}</code> to the user named "{{dest_username}}".<br />
|
||||
It will give full control of the tag to them and will not let you edit it anymore as it is a complete handover.
|
||||
</p>
|
||||
|
||||
<p>Do you wish to proceed?</p>
|
||||
|
||||
<div>
|
||||
<a href="{{proceed_url}}" class="btn">Yes, hand it over to them</a>
|
||||
<a href="/admin" class="btn">No, cancel the handover</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock main %}
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Admin panel - {% endblock title %}
|
||||
{% block main %}
|
||||
<aside>
|
||||
|
@ -8,6 +9,12 @@
|
|||
</p>
|
||||
</aside>
|
||||
|
||||
{% if toast %}
|
||||
<div class="raised">
|
||||
<p>{{toast|capitalize}}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>Service status</h2>
|
||||
|
||||
|
@ -22,4 +29,53 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{% set ctx = previous.tag_handover %}
|
||||
<h2>Tag handover</h2>
|
||||
<p>
|
||||
To begin handover of a tag, enter the tag's ID and destination account nickname below.<br />
|
||||
It will let you confirm the tag's account holder and the destination account before executing the handover.
|
||||
</p>
|
||||
|
||||
{% if ctx.form_errors | length > 0 %}
|
||||
<div class="form-error">
|
||||
<h3>Some errors were encountered...</h3>
|
||||
|
||||
<ul>
|
||||
{% for err in ctx.form_errors %}
|
||||
<li>{{err}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="form" value="TagHandover" />
|
||||
<div class="fields raised">
|
||||
<div class="dual-fields">
|
||||
<div>
|
||||
<p class="ident center">ID of the tag to hand over</p>
|
||||
<input type="text" name="tag_handover.tag_id" id="tag_id" placeholder="000000" minlength="6"
|
||||
maxlength="6" required {{form::value(ctx=ctx, name="tag_handover.tag_id" )}} />
|
||||
|
||||
{{form::error(ctx=ctx, name="tag_handover.tag_id")}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="ident center">Destination account username</p>
|
||||
<input type="text" name="tag_handover.dest_account" id="dest_account" autocomplete="off" required
|
||||
{{form::value(ctx=ctx, name="tag_handover.dest_account" )}} />
|
||||
|
||||
{{form::error(ctx=ctx, name="tag_handover.dest_account")}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<button type="submit">Begin tag handover</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
{% endblock main %}
|
Loading…
Add table
Reference in a new issue