add record creation in ui

This commit is contained in:
Hannaeko 2025-06-09 22:36:26 +01:00
parent 08b21ac010
commit 5f73738465
13 changed files with 253 additions and 146 deletions

View file

@ -0,0 +1,49 @@
function templateString(text, args) {
for (const argName in args) {
text = text.replace(`{${argName}}`, args[argName]);
}
return text;
}
function addFormRow(templateName) {
const allRows = document.querySelectorAll(`[data-new-item-template="${templateName}"]`);
const templateElement = allRows[0];
const nextId = allRows.length + 1;
const newItem = templateElement.cloneNode(true);
newItem.querySelectorAll('[data-new-item-skip]')
.forEach(node => node.remove());
newItem.querySelectorAll('input')
.forEach(input => { input.value = '' });
const templatedAttrNodes = newItem.querySelectorAll('[data-new-item-template-attr]');
for (const node of templatedAttrNodes) {
const attributes = node.dataset.newItemTemplateAttr.split(/\s/);
for (const attribute of attributes) {
const templatedString = node.getAttribute(`data-template-${attribute}`);
node.setAttribute(attribute, templateString(templatedString, {i: nextId}));
}
}
const templatedNodes = newItem.querySelectorAll('[data-new-item-template-content]');
for (const node of templatedNodes) {
const templatedString = node.dataset.newItemTemplateContent;
node.innerHTML = templateString(templatedString, {i: nextId})
}
allRows[allRows.length - 1].insertAdjacentElement('afterend', newItem);
}
function setUpAddFormRow() {
const buttons = document.querySelectorAll('button[data-new-item]');
for (const button of buttons) {
button.addEventListener('click', () => addFormRow(button.dataset.newItem))
}
}
document.addEventListener('DOMContentLoaded', setUpAddFormRow);

View file

@ -34,100 +34,106 @@ h2 {
article.domain {
margin-bottom: 2em;
header {
display: flex;
align-items: center;
height: 3em;
h3.folder-tab {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 1rem;
border-top-left-radius: .3rem;
background-color: #f2e0fd;
margin: 0;
font-weight: inherit;
font-size: 1.3rem;
~ .sep {
content: '';
width: 3em;
background-color: #f2e0fd;
height: 100%;
clip-path: url("#corner-folder-tab-right");
flex-shrink: 0;
}
}
}
}
article.domain header {
display: flex;
align-items: center;
height: 3em;
}
article.domain header h3.folder-tab {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 1rem;
border-top-left-radius: .3rem;
background-color: #f2e0fd;
margin: 0;
font-weight: inherit;
font-size: 1.3rem;
}
article.domain header h3.folder-tab ~ .sep {
content: '';
width: 3em;
background-color: #f2e0fd;
height: 100%;
clip-path: url("#corner-folder-tab-right");
flex-shrink: 0;
}
article.domain .records {
.records {
background: #f2e0fd;
padding: 1rem;
border-radius: 0 .3rem .3rem .3rem;
}
article.domain .records h4 {
margin: 0;
}
button,
a.button {
background-color: #f2e0fd;
}
article.domain .records > ul {
margin: 0;
padding: 0;
list-style: none;
}
h4 {
margin: 0;
}
article.domain .records .rrset .rtype {
display: flex;
align-items: baseline;
gap: .5em;
}
> ul {
margin: 0;
padding: 0;
list-style: none;
}
article.domain .records .rrset ul {
padding: 1rem 0 1rem 2rem;
display: flex;
flex-direction: column;
gap: .5rem;
}
.rrset {
.rtype {
display: flex;
align-items: baseline;
gap: .5em;
}
ul {
padding: 1rem 0 1rem 2rem;
display: flex;
flex-direction: column;
gap: .5rem;
}
article.domain .records .rrset li {
align-items: baseline;
position: relative;
display: flex;
gap: .5rem;
}
li {
align-items: baseline;
position: relative;
display: flex;
gap: .5rem;
}
article.domain .records .rrset .rdata {
display: flex;
gap: .2rem;
flex-wrap: wrap;
}
.rdata {
display: flex;
gap: .2rem;
flex-wrap: wrap;
}
article.domain .records .rrset .rdata-main {
display: flex;
gap: .3rem;
margin-right: .1rem;
}
.rdata-main {
display: flex;
gap: .3rem;
margin-right: .1rem;
article.domain .records .rrset .rdata-main .pill {
background-color: white;
}
.pill {
background-color: white;
}
}
article.domain .records .rrset .rdata-complementary {
font-size: .9em;
gap: .2rem;
display: flex;
}
.rdata-complementary {
font-size: .9em;
gap: .2rem;
display: flex;
}
article.domain .records .rrset .action {
display: flex;
gap: .5rem;
position: relative;
top: .15rem;
.action {
display: flex;
gap: .5rem;
position: relative;
top: .15rem;
}
}
}
.pill {
@ -151,36 +157,27 @@ a.button {
font-size: 1rem;
cursor: pointer;
transition: background-color .2s, color .2s;
}
button svg,
a.button svg {
height: 1em;
width: 1em;
}
svg {
height: 1em;
width: 1em;
}
.records button,
.records a.button {
background-color: #f2e0fd;
}
&.icon {
padding: 0;
width: 2em;
height: 2em;
}
button.icon,
a.button.icon {
padding: 0;
width: 2em;
height: 2em;
}
button:hover,
button:focus-visible,
a.button:hover,
a.button:focus-visible {
color: white;
background-color: #850085;
&:hover,
&:focus-visible {
color: white;
background-color: #850085;
}
}
form h3 {
margin-top: 0;
margin: 0;
}
fieldset {
@ -208,7 +205,7 @@ textarea {
flex-direction: column;
gap: .5rem;
flex-grow: 1;
margin-bottom: 1rem;
margin-top: 1rem;
}
.form-row {
@ -217,3 +214,23 @@ textarea {
gap: 1rem;
flex-wrap: wrap;
}
.form-action {
margin-top: 1rem;
}
button.form-new-item {
background: none;
border: none;
padding: 0;
text-decoration: underline;
border-radius: 0;
color: #850085;
&:hover,
&:focus-visible {
background: none;
text-decoration: none;
color: #850085;
}
}

View file

@ -37,3 +37,4 @@ record-input-addresses =
address, like <code>2001:db8:cafe:bc68::2</code>.
button-save-configuration = Save configuration
button-add-address = Add an other address

View file

@ -32,8 +32,9 @@ zone-content-new-record-button = Nouvel enregistrement
record-input-addresses =
.input-label = Adresse IP #{ $index }
.error-record-parse-ip = Format d'adresse IP inconnu. L'adresse IP doit être
.error-record-parse-ip = Format dadresse IP inconnu. Ladresse IP doit être
soit une adresse IPv4, comme <code>198.51.100.3</code>, soit une adresse IPv6,
comme <code>2001:db8:cafe:bc68::2</code>.
button-save-configuration = Sauvegarder la configuration
button-add-address = Ajouter une autre adresse

View file

@ -111,19 +111,19 @@ impl Node {
}).collect()
}
pub fn to_json_value(self) -> serde_json::Value {
pub fn into_json_value(self) -> serde_json::Value {
match self {
Node::Value(value) => serde_json::Value::String(value),
Node::Map(map) => {
let map = map.into_iter()
.map(|(key, node)| (key, node.to_json_value()))
.map(|(key, node)| (key, node.into_json_value()))
.collect();
serde_json::Value::Object(map)
},
Node::Sequence(list) => {
let array = list.to_vec()
.into_iter()
.map(|node| node.to_json_value())
.map(|node| node.into_json_value())
.collect();
serde_json::Value::Array(array)
@ -245,6 +245,6 @@ mod tests {
]
});
assert_eq!(parsed_form.to_json_value(), json_value);
assert_eq!(parsed_form.into_json_value(), json_value);
}
}

View file

@ -458,6 +458,5 @@ impl TryFrom<internal::Record> for RecordImpl {
let ttl = Ttl::from_secs(value.ttl);
let data = value.rdata.try_into()?;
Ok(Record::new(owner, Class::IN, ttl, data))
}
}

View file

@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use crate::validation;
use crate::errors::Error;
use crate::macros::{append_errors, check_type, get_object_value, push_error};
use crate::resources::dns::internal::{self, Name};
use crate::resources::dns::internal::{self, Name, Record};
use super::{rdata, FriendlyRType};
use super::FromValue;
@ -16,6 +16,21 @@ pub enum ConfigurationType {
Web,
}
impl ConfigurationType {
pub fn get_records(&self, input_data: serde_json::Value, default_ttl: u32, zone_name: Name) -> Result<Vec<Record>, Vec<Error>> {
match self {
ConfigurationType::Mail => {
NewSectionMail::from_value(input_data.clone())
.map(|section| section.internal(default_ttl, zone_name))
},
ConfigurationType::Web => {
NewSectionWeb::from_value(input_data.clone())
.map(|section| section.internal(default_ttl, zone_name))
},
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NewRecordQuery {
pub name: Option<String>,
@ -59,7 +74,7 @@ pub struct NewRecord<T> {
}
impl<T: FromValue> FromValue for NewRecord<T> {
fn from_value(value: tera::Value) -> Result<Self, Vec<crate::errors::Error>> {
fn from_value(value: serde_json::Value) -> Result<Self, Vec<crate::errors::Error>> {
let mut errors = Vec::new();
let object = check_type!(value, Object, errors);
@ -113,7 +128,7 @@ pub struct NewRequiredRecord<T> {
}
impl<T: FromValue> FromValue for NewRequiredRecord<T> {
fn from_value(value: tera::Value) -> Result<Self, Vec<crate::errors::Error>> {
fn from_value(value: serde_json::Value) -> Result<Self, Vec<crate::errors::Error>> {
let mut errors = Vec::new();
let object = check_type!(value, Object, errors);

View file

@ -304,11 +304,10 @@ impl FromValue for Spf {
impl ToInternal for Spf {
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record> {
vec![
internal::Record {
ttl,
name: Name::new(format!("_spf.{node_name}")),
name: node_name,
rdata: internal::RData::Txt(internal::Txt::new(self.policy))
}
]

View file

@ -14,6 +14,9 @@ pub struct RecordList {
}
impl RecordList {
pub fn new(records: &[Record]) -> Self {
RecordList { records: records.into() }
}
pub fn sort(&mut self) {
self.records.sort_by(|r1, r2| {
let key1 = (&r1.name, r1.rdata.rtype());

View file

@ -1,4 +1,6 @@
use async_trait::async_trait;
use axum::response::Response;
use domain::base::record;
use serde::{Deserialize, Serialize};
use rusqlite::Error as RusqliteError;
@ -7,7 +9,7 @@ use crate::database::{BoxedDb, sqlite::SqliteDB};
use crate::dns::{BoxedZoneDriver, BoxedRecordDriver, DnsDriverError};
use crate::errors::Error;
use crate::macros::push_error;
use crate::resources::dns::internal::RecordList;
use crate::resources::dns::internal::{self, RecordList};
use crate::validation;
pub enum ZoneError {
@ -48,6 +50,12 @@ impl Zone {
Ok(records)
}
pub async fn add_records(&self, record_driver: BoxedRecordDriver, records: internal::RecordList) -> Result<(), Error> {
record_driver.add_records(&self.name, records).await?;
Ok(())
}
}
#[derive(Deserialize)]

View file

@ -1,4 +1,6 @@
use axum::extract::{Query, Path, State, OriginalUri};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Extension;
use serde_json::{Value, json};
use unic_langid::LanguageIdentifier;
@ -8,7 +10,7 @@ use crate::macros::append_errors;
use crate::AppState;
use crate::errors::{Error, error_map};
use crate::template::Template;
use crate::resources::dns::friendly::{self, NewRecordQuery, ConfigurationType, FromValue, NewSectionMail, NewSectionWeb, ToInternal};
use crate::resources::dns::friendly::{self, NewRecordQuery};
use crate::resources::dns::internal;
pub async fn get_records_page(
@ -68,33 +70,21 @@ pub async fn post_new_record(
OriginalUri(url): OriginalUri,
Extension(lang): Extension<LanguageIdentifier>,
form: Node,
) -> Result<Template<'static, Value>, Error> {
) -> Result<Response, Error> {
let zone = app.db.get_zone_by_name(&zone_name).await?;
let mut errors = Vec::new();
append_errors!(params.validate(&zone.name), errors);
if !errors.is_empty() || params.name.is_none() || !(params.config.is_none() ^ params.config.is_some()) {
// TODO: return 404
todo!()
return Ok(StatusCode::NOT_FOUND.into_response());
}
let name = params.name.clone().unwrap();
let input_data = form.to_json_value();
let new_records = if errors.is_empty() {
let name = internal::Name::new(name);
let name = params.name.clone().map(internal::Name::new).unwrap();
let input_data = form.into_json_value();
let new_records = {
let new_records = if let Some(config_type) = params.config.clone() {
match config_type {
ConfigurationType::Mail => {
NewSectionMail::from_value(input_data.clone())
.map(|section| section.internal(3600, name))
},
ConfigurationType::Web => {
NewSectionWeb::from_value(input_data.clone())
.map(|section| section.internal(3600, name))
},
}
config_type.get_records(input_data.clone(), 3600, name)
} else if let Some(_rtype) = params.rtype {
unimplemented!()
} else {
@ -102,11 +92,16 @@ pub async fn post_new_record(
};
append_errors!(new_records, errors)
} else {
None
};
if !errors.is_empty() {
if let Some(new_records) = new_records {
zone.add_records(
app.records,
internal::RecordList::new(&new_records)
).await?;
Ok(().into_response())
} else {
Ok(Template::new(
"pages/new_record.html",
app.template_engine,
@ -120,10 +115,7 @@ pub async fn post_new_record(
"url": url.to_string(),
"lang": lang.to_string(),
})
))
} else {
println!("{:#?}", new_records);
todo!()
).into_response())
}
}

View file

@ -12,3 +12,7 @@
{% block main %}{% endblock main %}
</main>
{% endblock content %}
{% block scripts %}
<script src="/assets/scripts/add-form-row.js"></script>
{% endblock scripts %}

View file

@ -14,19 +14,27 @@
{% for address in input_data.addresses.data.addresses | default(value=[""]) %}
{% set address_error = errors | get(key="/addresses/data/addresses/" ~ loop.index0 ~ "/address", default="") %}
<div class="form-input">
<label for="address-{{ loop.index0 }}">
<div class="form-input" data-new-item-template="address">
<label
for="address-{{ loop.index0 }}"
data-new-item-template-attr="for"
data-template-for="address-{i}"
data-new-item-template-content="{{ tr(msg="record-input-addresses", attr="input-label", index="{i}", lang=lang) }}"
>
{{ tr(msg="record-input-addresses", attr="input-label", index=loop.index, lang=lang) }}
</label>
<input
type="text"
name="addresses[data][addresses][{{ loop.index0 }}][address]"
id="address-{{ loop.index0 }}"
data-new-item-template-attr="name id"
data-template-name="addresses[data][addresses][{i}][address]"
data-template-id="address-{i}"
{% if domain_error %}aria-invalid="true"{% endif %}
value="{{ address.address | default(value="") }}"
>
{% if address_error %}
<p class="error" id="address-{{ loop.index0 }}-error">
<p class="error" id="address-{{ loop.index0 }}-error" data-new-item-skip>
{{ tr(
msg="record-input-addresses",
attr="error-" ~ address_error.code | replace(from=":", to="-"),
@ -34,9 +42,18 @@
</p>
{% endif %}
</div>
<button class="form-new-item" type="button" data-new-item="address">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
</svg>
{{ tr(msg="button-add-address", lang=lang) }}
</button>
{% endfor %}
<button type="submit">{{ tr(msg="button-save-configuration", lang=lang) }}</button>
<div class="form-action">
<button type="submit">{{ tr(msg="button-save-configuration", lang=lang) }}</button>
</div>
</form>
{% elif config == "mail" %}
@ -104,6 +121,8 @@
</fieldset>
<button type="submit">Save configuration</button>
<div class="form-action">
<button type="submit">{{ tr(msg="button-save-configuration", lang=lang) }}</button>
</div>
</form>
{% endif %}