Compare commits
10 commits
515cc06dec
...
0801e83d3e
Author | SHA1 | Date | |
---|---|---|---|
0801e83d3e | |||
d06d745dcb | |||
960942c47f | |||
e15ad04c64 | |||
c910ca5f99 | |||
d0df3584c8 | |||
9fec0cc643 | |||
3c5de4cab6 | |||
1cd58f6ff7 | |||
473daa532a |
25 changed files with 897 additions and 224 deletions
|
@ -1,7 +1,7 @@
|
||||||
-- Your SQL goes here
|
-- Your SQL goes here
|
||||||
CREATE TABLE localuser (
|
CREATE TABLE localuser (
|
||||||
`user_id` VARCHAR NOT NULL PRIMARY KEY,
|
`user_id` VARCHAR NOT NULL PRIMARY KEY,
|
||||||
`username` VARCHAR NOT NULL UNIQUE,
|
`email` VARCHAR NOT NULL UNIQUE,
|
||||||
`password` VARCHAR NOT NULL,
|
`password` VARCHAR NOT NULL,
|
||||||
`role` TEXT CHECK(role IN ('admin', 'zoneadmin')) NOT NULL, -- note: migrate to postgres so enum are actually a thing
|
`role` TEXT CHECK(role IN ('admin', 'zoneadmin')) NOT NULL, -- note: migrate to postgres so enum are actually a thing
|
||||||
FOREIGN KEY(user_id) REFERENCES user(id)
|
FOREIGN KEY(user_id) REFERENCES user(id)
|
||||||
|
|
3
public/images/plus.svg
Normal file
3
public/images/plus.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 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 4z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 245 B |
|
@ -12,11 +12,33 @@ function apiGet(url) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiPost(url, data) {
|
||||||
|
return fetch(`${baseUrl}/${url}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
// do something here
|
||||||
|
throw new Error('Not ok');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function getRecords(zone) {
|
function getRecords(zone) {
|
||||||
return apiGet(`zones/${zone}/records`);
|
return apiGet(`zones/${zone}/records`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRecords(zone, record) {
|
||||||
|
return apiPost(`zones/${zone}/records`, record);
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getRecords,
|
getRecords,
|
||||||
|
createRecords,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,117 +1,329 @@
|
||||||
import { html, Component, render, createContext, useState, useEffect } from './vendor/preact/standalone.js';
|
import { html, render, useState, useEffect } from './vendor/preact/standalone.js';
|
||||||
|
|
||||||
import { getRecords } from './api.js';
|
import { getRecords, createRecords } from './api.js';
|
||||||
|
|
||||||
|
|
||||||
const rdataInputProperties = {
|
const rdataInputProperties = {
|
||||||
Address: {label: 'adresse', type: 'text'},
|
Address: {label: 'Adresse', type: 'text'},
|
||||||
Serial: {label: 'serial', type: 'number'},
|
Serial: {label: 'Numéro de série', type: 'number'},
|
||||||
Minimum: {label: 'minimum', type: 'number'},
|
Minimum: {label: 'Minimum', type: 'number'},
|
||||||
Retry: {label: 'nouvelle tentative', type: 'number'},
|
Retry: {label: 'Nouvelle tentative', type: 'number'},
|
||||||
Refresh: {label: 'actualisation', type: 'number'},
|
Refresh: {label: 'Actualisation', type: 'number'},
|
||||||
MaintainerName: {label: 'contact', type: 'text'},
|
MaintainerName: {label: 'Contact', type: 'text'},
|
||||||
MasterServerName: {label: 'serveur primaire', type: 'text'},
|
MasterServerName: {label: 'Serveur primaire', type: 'text'},
|
||||||
Expire: {label: 'expiration', type: 'number'},
|
Expire: {label: 'Expiration', type: 'number'},
|
||||||
Target: {label: 'cible', type: 'text'},
|
Target: {label: 'Cible', type: 'text'},
|
||||||
|
Service: {label: 'Service', type: 'text'},
|
||||||
|
Protocol: {label: 'Protocole', type: 'text'},
|
||||||
|
Priority: {label: 'Priorité', type: 'number'},
|
||||||
|
Weight: {label: 'Poids', type: 'number'},
|
||||||
|
Port: {label: 'Port', type: 'number'},
|
||||||
|
Server: {label: 'Serveur', type: 'text'},
|
||||||
};
|
};
|
||||||
|
|
||||||
const Editable = createContext(false);
|
const realRecordDataConfig = {
|
||||||
|
'A': {
|
||||||
|
friendlyType: 'address',
|
||||||
|
fields: ['Address'],
|
||||||
|
},
|
||||||
|
'AAAA': {
|
||||||
|
friendlyType: 'address',
|
||||||
|
fields: ['Address'],
|
||||||
|
},
|
||||||
|
'CNAME': {
|
||||||
|
friendlyType: 'alias',
|
||||||
|
fields: ['Target'],
|
||||||
|
},
|
||||||
|
'SRV': {
|
||||||
|
friendlyType: 'service',
|
||||||
|
fields: [ 'Priority', 'Weight', 'Port', 'Server' ],
|
||||||
|
},
|
||||||
|
'NS': {
|
||||||
|
friendlyType: 'name_server',
|
||||||
|
fields: ['Target'],
|
||||||
|
},
|
||||||
|
'SOA': {
|
||||||
|
friendlyType: 'soa',
|
||||||
|
fields: ['MasterServerName', 'MaintainerName', 'Refresh', 'Retry', 'Expire', 'Minimum', 'Serial'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultBuildData(realRecordType) {
|
||||||
function RDataInput({ name, value = '', index = 0 }) {
|
const defaultFields = Object.fromEntries(realRecordDataConfig[realRecordType].fields.map(field => [field, null]));
|
||||||
const {label, type} = rdataInputProperties[name] || {label: name, type: 'text'};
|
return (fields) => {
|
||||||
|
return {...defaultFields, ...fields, Type: realRecordType};
|
||||||
return html`
|
}
|
||||||
<${Editable.Consumer}>
|
|
||||||
${
|
|
||||||
(editable) => {
|
|
||||||
if (editable) {
|
|
||||||
return html`
|
|
||||||
<div>
|
|
||||||
<label for=record_${index}_${name}>${label}:</label>
|
|
||||||
<input id=record_${index}_${name} type=${type} value=${value} />
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
return html`
|
|
||||||
<div>
|
|
||||||
<dt>${label}:</dt>
|
|
||||||
<dd>${value}</dd>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<//>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RData({ rdata, index }) {
|
function defaultRecordToFields(realRecord) {
|
||||||
const { Address: address } = rdata;
|
const type = realRecord.Type;
|
||||||
return Object.entries(rdata).map(([name, value]) => html`<${RDataInput} name=${name} value=${value} index=${index} />`);
|
return realRecordDataConfig[type].fields.map(field => [field, realRecord[field]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultGetName(name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function srvRecordToFields({ Name, Type, Class, ...fields }) {
|
||||||
|
const [ serviceName, protocol] = Name.split('.');
|
||||||
|
return {
|
||||||
|
Service: serviceName.replace(/^_/, ''),
|
||||||
|
Protocol: protocol.replace(/^_/, ''),
|
||||||
|
...fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function srvGetName(originalName) {
|
||||||
|
const [_serviceName, _protocol, ...name] = originalName.split('.');
|
||||||
|
return name.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAddressRecord(fields) {
|
||||||
|
const address = fields.Address || '';
|
||||||
|
if (address.indexOf('.') >= 0) {
|
||||||
|
fields.Type = 'A';
|
||||||
|
} else if (address.indexOf(':') >= 0) {
|
||||||
|
fields.Type = 'AAAA';
|
||||||
|
} else {
|
||||||
|
fields.Type = '';
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServiceRecord({ Name, Service, Protocol, ...fields}) {
|
||||||
|
fields.Name = `_${Service}._${Protocol}.${Name}`;
|
||||||
|
fields.Type = 'SRV';
|
||||||
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function Record({name, ttl, type, rdata, index = 0}) {
|
const friendlyRecordDataConfig = {
|
||||||
return html`
|
'address': {
|
||||||
<tr>
|
realRecordToFields: defaultRecordToFields,
|
||||||
<td class=domain>${name}</div>
|
fields: realRecordDataConfig['AAAA'].fields,
|
||||||
<td class=type>${type}</div>
|
buildData: buildAddressRecord,
|
||||||
<td class=ttl>${ttl}</div>
|
getName: defaultGetName,
|
||||||
<td class=rdata>
|
},
|
||||||
<${Editable.Consumer}>
|
'alias': {
|
||||||
${
|
realRecordToFields: defaultRecordToFields,
|
||||||
(editable) => {
|
fields: realRecordDataConfig['CNAME'].fields,
|
||||||
if (editable) {
|
buildData: defaultBuildData('CNAME'),
|
||||||
return html`<${RData} rdata=${rdata} index=${index}/>`
|
getName: defaultGetName,
|
||||||
} else {
|
},
|
||||||
return html`<dl><${RData} rdata=${rdata} index=${index}/></dl>`
|
'name_server': {
|
||||||
}
|
realRecordToFields: defaultRecordToFields,
|
||||||
}
|
fields: realRecordDataConfig['CNAME'].fields,
|
||||||
}
|
buildData: defaultBuildData('NS'),
|
||||||
|
getName: defaultGetName,
|
||||||
|
},
|
||||||
|
'soa': {
|
||||||
|
realRecordToFields: defaultRecordToFields,
|
||||||
|
fields: realRecordDataConfig['SOA'].fields,
|
||||||
|
buildData: defaultBuildData('SOA'),
|
||||||
|
getName: defaultGetName,
|
||||||
|
},
|
||||||
|
'service': {
|
||||||
|
realRecordToFields: srvRecordToFields,
|
||||||
|
fields: ['Service', 'Protocol', 'Priority', 'Weight', 'Port', 'Server'],
|
||||||
|
buildData: buildServiceRecord,
|
||||||
|
getName: srvGetName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
<//>
|
|
||||||
|
const recordTypeNames = {
|
||||||
|
'address': 'Adresse IP',
|
||||||
|
'service': 'Service',
|
||||||
|
'alias': 'Alias',
|
||||||
|
'name_server': 'Serveur de nom',
|
||||||
|
'soa': 'SOA',
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Name to use with _spf for example */
|
||||||
|
function getFriendlyTypeForRecord(name, type) {
|
||||||
|
return realRecordDataConfig[type].friendlyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processRecords(records) {
|
||||||
|
return records.reduce((acc, record) => {
|
||||||
|
let type = getFriendlyTypeForRecord(record.Name, record.Type);
|
||||||
|
let name = friendlyRecordDataConfig[type].getName(record.Name);
|
||||||
|
if (!(name in acc)) {
|
||||||
|
acc[name] = {};
|
||||||
|
}
|
||||||
|
if (!(type in acc[name])) {
|
||||||
|
acc[name][type] = [];
|
||||||
|
}
|
||||||
|
acc[name][type].push(record);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function FriendlyRecord({type, record}) {
|
||||||
|
let keys = friendlyRecordDataConfig[type].realRecordToFields(record);
|
||||||
|
if (keys.length == 1) {
|
||||||
|
return html`<span>${keys[0][1]}</span>`;
|
||||||
|
} else {
|
||||||
|
return html`
|
||||||
|
<dl>
|
||||||
|
${keys.map(([name, value]) => {return html`<dt><span>${rdataInputProperties[name].label}</span></dt><dd>${value}</dd>`})}
|
||||||
|
</dl>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordsByName({ name, recordSets }) {
|
||||||
|
return html`
|
||||||
|
<article class="rrsets-per-name">
|
||||||
|
<h3 class="record-name">${name}</h4>
|
||||||
|
<div class="rrsets-per-type">
|
||||||
|
${Object.entries(recordSets).map(
|
||||||
|
([type, records]) => {
|
||||||
|
return html`
|
||||||
|
<article class="rrset-per-type">
|
||||||
|
<h4 class="record-type">${recordTypeNames[type]}</h4>
|
||||||
|
<ul class="rrset-rdata">
|
||||||
|
${records.map(record => html`<li><${FriendlyRecord} type=${type} record=${record}/></li>`)}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</tr>
|
</article>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordList({ zone }) {
|
function RecordListFriendly({ zone }) {
|
||||||
const [records, setRecords] = useState([]);
|
const [records, setRecords] = useState({});
|
||||||
const [editable, setEditable] = useState(false);
|
const [editable, setEditable] = useState(false);
|
||||||
|
|
||||||
const toggleEdit = () => setEditable(!editable);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getRecords(zone)
|
getRecords(zone)
|
||||||
.then((res) => setRecords(res));
|
.then((res) => setRecords(processRecords(res)));
|
||||||
}, []);
|
}, [zone]);
|
||||||
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<${Editable.Provider} value=${editable}>
|
${Object.entries(records).map(
|
||||||
<button onclick=${toggleEdit}>${ editable ? 'Save' : 'Edit'}</button>
|
([name, recordSets]) => {
|
||||||
<table>
|
return html`
|
||||||
<thead>
|
<${RecordsByName} name=${name} recordSets=${recordSets}/>
|
||||||
<tr>
|
`;
|
||||||
<th>Nom</th>
|
}
|
||||||
<th>Type</th>
|
)}
|
||||||
<th>TTL</th>
|
`;
|
||||||
<th>Données</th>
|
}
|
||||||
</tr>
|
|
||||||
</thead>
|
function NewRecordFormFriendly({ zone }) {
|
||||||
<tbody>
|
const defaultVaules = {Name: '', TTL: 3600, Class: 'IN'};
|
||||||
${records.map(
|
const [recordType, setRecordType] = useState(Object.keys(recordTypeNames)[0]);
|
||||||
({Name, Class, TTL, Type, ...rdata}, index) => {
|
const [recordData, setRecordData] = useState(defaultVaules);
|
||||||
return html`<${Record} name=${Name} ttl=${TTL} type=${Type} rdata=${rdata} index=${index}/>`
|
const [realRecordData, setRealRecordData] = useState({});
|
||||||
}
|
const [realType, setRealType] = useState('');
|
||||||
)}
|
|
||||||
</tbody>
|
const absoluteName = (name) => name ? `${name}.${zone}` : zone;
|
||||||
</ul>
|
|
||||||
<//>
|
const setRecordDataFactory = (field) => {
|
||||||
|
return (e) => {
|
||||||
|
const newData = {...recordData};
|
||||||
|
newData[field] = e.target.type == 'number' ? Number(e.target.value) : e.target.value;
|
||||||
|
const newRealRecordData = friendlyRecordDataConfig[recordType].buildData({...newData, Class: 'IN', Name: absoluteName(newData.Name)})
|
||||||
|
|
||||||
|
setRecordData(newData);
|
||||||
|
setRealRecordData(newRealRecordData);
|
||||||
|
setRealType(newRealRecordData.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewRecord = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newRecords = [realRecordData];
|
||||||
|
console.log(newRecords)
|
||||||
|
createRecords(zone, newRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetData = (resetName = false) => {
|
||||||
|
setRealType('');
|
||||||
|
const newName = resetName ? defaultVaules.Name : recordData.Name;
|
||||||
|
setRecordData({ Name: newName, TTL: defaultVaules.TTL });
|
||||||
|
setRealRecordData({...defaultVaules, Name: absoluteName(newName)});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => resetData(true), []);
|
||||||
|
|
||||||
|
// TODO: Reset valeurs champs quand changement de type + "annuler" => bound la valeur de l'input au state
|
||||||
|
// TODO: Dans le cas où un domain est dans le RDATA mettre le domaine absolue dans la preview
|
||||||
|
// TODO: Déplacer preview dans son component, faire une vue en "diff" et l'appeler "prévisualisation des changements"
|
||||||
|
// TODO: Validation des données client et serveur
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<section class="new-record">
|
||||||
|
<header>
|
||||||
|
<h2>Nouvel enregistrement</h2>
|
||||||
|
</header>
|
||||||
|
<form>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="domain">Domaine</label>
|
||||||
|
<div class="combined-input">
|
||||||
|
<input type="text" id="domain" name="domain" value=${recordData.Name} onInput=${setRecordDataFactory('Name')}/>
|
||||||
|
<span>.${ zone }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="record_type">Type d'enregistrement</label>
|
||||||
|
<select id="record_type" name="record_type" onChange=${(e) => { setRecordType(e.target.value); resetData() }}>
|
||||||
|
${Object.entries(recordTypeNames).map(([type, name]) => html`<option value="${type}">${name}</option>`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
${friendlyRecordDataConfig[recordType].fields.map(fieldName => html`
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="${fieldName}">${rdataInputProperties[fieldName].label}</label>
|
||||||
|
<input id="${fieldName}" type="${rdataInputProperties[fieldName].type}" onInput=${setRecordDataFactory(fieldName)}></input>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="ttl">Durée dans le cache</label>
|
||||||
|
<input type="number" name="ttl" id="ttl" value=${recordData.TTL} onInput=${setRecordDataFactory('TTL')}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<article class="preview">
|
||||||
|
<h3>Prévisualisation des changements</h3>
|
||||||
|
<p>
|
||||||
|
<img src="/images/plus.svg" alt="Ajout" title="Ajout" class="addition"/>
|
||||||
|
<code class="addition">
|
||||||
|
${realRecordData.Name === zone ? '@' : realRecordData.Name} ${realRecordData.TTL} ${realRecordData.Class} ${realType} ${realType != '' ? realRecordDataConfig[realType].fields.map(field => realRecordData[field]).join(' ') : ''}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<div>
|
||||||
|
<input type="submit" onClick=${createNewRecord} value="Ajouter"/>
|
||||||
|
<button type="reset" onClick=${e => { resetData(true); e.preventDefault() }}>Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZoneRecords({ zone }) {
|
||||||
|
return html`
|
||||||
|
<${NewRecordFormFriendly} zone=${zone}/>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<header>
|
||||||
|
<h2>Contenu de la zone</h2>
|
||||||
|
<button>Éditer la zone</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="zone-content">
|
||||||
|
<${RecordListFriendly} zone=${zone} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function(element, { zone }) {
|
export default function(element, { zone }) {
|
||||||
render(html`<${RecordList} zone=${zone} />`, element);
|
render(html`<${ZoneRecords} zone=${zone} />`, element);
|
||||||
};
|
};
|
||||||
|
|
13
public/styles/login.css
Normal file
13
public/styles/login.css
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
form {
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 40ch;
|
||||||
|
margin: 25vh auto 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
|
@ -1,62 +1,158 @@
|
||||||
body {
|
body {
|
||||||
color: #2e2033;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
min-height: 100vh;
|
||||||
width: 100%;
|
min-width: 100vw;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: 94, 12, 151;
|
||||||
|
--color-hightlight-1: 255, 212, 186;
|
||||||
|
--color-hightlight-2: 208, 44, 167;
|
||||||
|
--color-contrast: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
zone-content table {
|
h1 {
|
||||||
border-collapse: collapse;
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(var(--color-primary));
|
||||||
|
text-decoration: none;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: .3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border-bottom: 2px solid rgb(var(--color-primary));
|
||||||
|
position: absolute;
|
||||||
|
bottom: .1em;
|
||||||
|
transition: .2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
zone-content .rdata {
|
a:hover::after {
|
||||||
display: flex;
|
bottom: .3em;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
zone-content th, .zone-content td {
|
p.feedback {
|
||||||
font-weight: normal;
|
padding: .35rem;
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
padding: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
zone-content thead {
|
|
||||||
background: #ccb9ff;
|
|
||||||
color: #39004d;
|
|
||||||
}
|
|
||||||
|
|
||||||
zone-content tbody tr:nth-child(even) td {
|
|
||||||
background: #ccb9ff3d;
|
|
||||||
}
|
|
||||||
|
|
||||||
zone-content tbody tr .rdata dt,
|
|
||||||
zone-content tbody tr .rdata label {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.1em 0.5em;
|
|
||||||
background: #cecece;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
margin-right: 0.1rem;
|
|
||||||
}
|
|
||||||
zone-content tbody tr .rdata dd {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
zone-content tbody tr .rdata dl {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
zone-content tbody tr .rdata div {
|
p.feedback.error {
|
||||||
margin: 0.1rem 0.5rem 0.1rem 0;
|
background: #fddede;
|
||||||
display: flex;
|
color: #710000;
|
||||||
align-items: baseline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input {
|
||||||
|
border: 1px solid rgb(var(--color-primary));;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
padding: .35rem .35rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
background: rgb(var(--color-primary));
|
||||||
|
color: var(--color-contrast);
|
||||||
|
border-left: 5px solid rgb(var(--color-hightlight-1));
|
||||||
|
border-top: 5px solid rgb(var(--color-hightlight-1));
|
||||||
|
border-right: 5px solid rgb(var(--color-hightlight-2));
|
||||||
|
border-bottom: 5px solid rgb(var(--color-hightlight-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not([disabled]),
|
||||||
|
input[type="submit"]:hover:not([disabled]) {
|
||||||
|
background: rgba(var(--color-primary), .8);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active:not([disabled]) ,
|
||||||
|
input[type="submit"]:active:not([disabled]) {
|
||||||
|
border-left: 5px solid var(--color-hightlight-2);
|
||||||
|
border-top: 5px solid var(--color-hightlight-2);
|
||||||
|
border-right: 5px solid rgb(var(--color-hightlight-1));
|
||||||
|
border-bottom: 5px solid rgb(var(--color-hightlight-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled],
|
||||||
|
input[type="submit"][disabled] {
|
||||||
|
background: rgba(var(--color-primary), .75);
|
||||||
|
border-left-color: rgba(var(--color-hightlight-1), .75);
|
||||||
|
border-top-color: rgba(var(--color-hightlight-1), .75);
|
||||||
|
border-right-color: rgba(var(--color-hightlight-2), .75);
|
||||||
|
border-bottom-color: rgba(var(--color-hightlight-2), .75);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
form input[type="submit"] {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form label {
|
||||||
|
margin-top: .75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.main {
|
||||||
|
background: rgb(var(--color-primary));
|
||||||
|
min-width: 25ch;
|
||||||
|
display: flex;
|
||||||
|
flex: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-right: 5px solid rgb(var(--color-hightlight-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.main a {
|
||||||
|
color: var(--color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.main a::after {
|
||||||
|
border-bottom: 2px solid var(--color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
nav.main a img {
|
||||||
|
filter: invert(100%);
|
||||||
|
width: 1.4em;
|
||||||
|
margin-bottom: -.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
nav.main ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.main ul li {
|
||||||
|
margin-top: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.main ul ul {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
183
public/styles/zone.css
Normal file
183
public/styles/zone.css
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
nav.secondary ul {
|
||||||
|
display: flex;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.secondary li {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main > section {
|
||||||
|
max-width: 120ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
header > :not(:last-of-type) {
|
||||||
|
margin-right: 2ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content article.rrsets-per-name {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
grid-gap: 2ch;
|
||||||
|
margin: .5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content article.rrsets-per-name:not(:last-of-type) {
|
||||||
|
border-bottom: 2px solid rgb(var(--color-hightlight-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content h3.record-name,
|
||||||
|
.zone-content h4.record-type {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content h3.record-name {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content div.rrsets-per-type {
|
||||||
|
grid-column: 3 / 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content article.rrset-per-type {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
display: grid;
|
||||||
|
grid-gap: 2ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content h4.record-type {
|
||||||
|
grid-column: 1 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content ul.rrset-rdata {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
grid-column: 2 / 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content ul.rrset-rdata dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template: auto / max-content 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content ul.rrset-rdata dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-content ul.rrset-rdata dt span {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: rgb(var(--color-hightlight-1));
|
||||||
|
padding: 0.1em 0.5em;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
font-size: .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form > div.form-row > * {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form > div.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form label {
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form div.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
form div.combined-input {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
form div.combined-input input {
|
||||||
|
height: min-content;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
form div.combined-input span {
|
||||||
|
font-size: .8rem;
|
||||||
|
padding: .35rem;
|
||||||
|
border: 1px solid rgb(var(--color-primary));;
|
||||||
|
border-left: none;
|
||||||
|
background: rgba(var(--color-hightlight-2),.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.disabled {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form button,
|
||||||
|
.new-record form input[type="submit"] {
|
||||||
|
margin-right: 1ch;
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form .preview {
|
||||||
|
margin: .5rem 0;
|
||||||
|
border: 1px solid rgb(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form .preview p:first-of-type {
|
||||||
|
margin-top: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form .preview p:last-of-type {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form .preview p {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form .preview code {
|
||||||
|
padding: 0 .5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form .preview img {
|
||||||
|
padding: 0 .25rem;
|
||||||
|
border-right: 1px solid #1b841b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form .preview .addition {
|
||||||
|
background: #d9fbd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record form .preview h3 {
|
||||||
|
margin: 0;
|
||||||
|
padding: .0rem .5rem 0 .5rem;;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: normal;
|
||||||
|
background: rgb(var(--color-primary));
|
||||||
|
color: var(--color-contrast)
|
||||||
|
}
|
|
@ -104,6 +104,8 @@ impl NomiloCommand for RunServerCommand {
|
||||||
ui::post_login_page,
|
ui::post_login_page,
|
||||||
ui::get_zones_page,
|
ui::get_zones_page,
|
||||||
ui::get_zone_records_page,
|
ui::get_zone_records_page,
|
||||||
|
ui::get_create_zone_page,
|
||||||
|
ui::post_create_zone_page,
|
||||||
])
|
])
|
||||||
.mount("/", static_files)
|
.mount("/", static_files)
|
||||||
.launch().await;
|
.launch().await;
|
||||||
|
|
|
@ -17,8 +17,6 @@ pub enum UserCommand {
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
pub struct AddUserCommand {
|
pub struct AddUserCommand {
|
||||||
#[clap(long = "--name", short = 'n')]
|
|
||||||
pub name: String,
|
|
||||||
#[clap(long = "--email", short = 'e')]
|
#[clap(long = "--email", short = 'e')]
|
||||||
pub email: String,
|
pub email: String,
|
||||||
#[clap(long = "--is-admin", short = 'a')]
|
#[clap(long = "--is-admin", short = 'a')]
|
||||||
|
@ -39,7 +37,6 @@ impl NomiloCommand for UserCommand {
|
||||||
impl NomiloCommand for AddUserCommand {
|
impl NomiloCommand for AddUserCommand {
|
||||||
fn run(self, figment: Figment, _app_config: Config) {
|
fn run(self, figment: Figment, _app_config: Config) {
|
||||||
let res = LocalUser::create_user(&get_db_conn(&figment), CreateUserRequest {
|
let res = LocalUser::create_user(&get_db_conn(&figment), CreateUserRequest {
|
||||||
username: self.name,
|
|
||||||
email: self.email,
|
email: self.email,
|
||||||
role: Some(if self.is_admin { Role::Admin } else { Role::ZoneAdmin }),
|
role: Some(if self.is_admin { Role::Admin } else { Role::ZoneAdmin }),
|
||||||
password: self.password.unwrap(),
|
password: self.password.unwrap(),
|
||||||
|
|
|
@ -2,6 +2,7 @@ use rocket::http::{Cookie, SameSite, CookieJar};
|
||||||
use rocket::State;
|
use rocket::State;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::dns::ZoneConnector;
|
||||||
use crate::DbConn;
|
use crate::DbConn;
|
||||||
use crate::models;
|
use crate::models;
|
||||||
|
|
||||||
|
@ -12,12 +13,12 @@ pub async fn do_login(
|
||||||
auth_request: models::AuthTokenRequest,
|
auth_request: models::AuthTokenRequest,
|
||||||
cookies: &CookieJar<'_>
|
cookies: &CookieJar<'_>
|
||||||
) -> Result<models::Session, models::UserError> {
|
) -> Result<models::Session, models::UserError> {
|
||||||
let session_duration = config.web_app.token_duration;
|
let session_duration = config.web_app.token_duration;
|
||||||
|
|
||||||
let session = conn.run(move |c| {
|
let session = conn.run(move |c| {
|
||||||
let user_info = models::LocalUser::get_user_by_creds(
|
let user_info = models::LocalUser::get_user_by_creds(
|
||||||
c,
|
c,
|
||||||
&auth_request.username,
|
&auth_request.email,
|
||||||
&auth_request.password
|
&auth_request.password
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
@ -39,3 +40,35 @@ pub async fn do_login(
|
||||||
|
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn create_zone(
|
||||||
|
conn: &DbConn,
|
||||||
|
mut dns_api: Box<dyn ZoneConnector>,
|
||||||
|
user_info: models::UserInfo,
|
||||||
|
zone_request: models::CreateZoneRequest,
|
||||||
|
) -> Result<models::Zone, models::ErrorResponse> {
|
||||||
|
user_info.check_admin()?;
|
||||||
|
|
||||||
|
dns_api.zone_exists(zone_request.name.clone(), models::DNSClass::IN.into()).await?;
|
||||||
|
|
||||||
|
let zone = conn.run(move |c| {
|
||||||
|
models::Zone::create_zone(c, zone_request)
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
Ok(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_zones(
|
||||||
|
conn: &DbConn,
|
||||||
|
user_info: models::UserInfo,
|
||||||
|
) -> Result<Vec<models::Zone>, models::ErrorResponse> {
|
||||||
|
let zones = conn.run(move |c| {
|
||||||
|
if user_info.is_admin() {
|
||||||
|
models::Zone::get_all(c)
|
||||||
|
} else {
|
||||||
|
user_info.get_zones(c)
|
||||||
|
}
|
||||||
|
}).await?;
|
||||||
|
Ok(zones)
|
||||||
|
}
|
||||||
|
|
|
@ -31,8 +31,10 @@ impl DerefMut for DnsClient {
|
||||||
|
|
||||||
impl DnsClient {
|
impl DnsClient {
|
||||||
pub async fn from_config(dns_config: &DnsClientConfig) -> Result<Self, ProtoError> {
|
pub async fn from_config(dns_config: &DnsClientConfig) -> Result<Self, ProtoError> {
|
||||||
|
info!("Creating DNS client for {}", dns_config.server);
|
||||||
let (stream, handle) = TcpClientStream::<AsyncIoTokioAsStd<TokioTcpStream>>::new(dns_config.server);
|
let (stream, handle) = TcpClientStream::<AsyncIoTokioAsStd<TokioTcpStream>>::new(dns_config.server);
|
||||||
let signer = if let Some(tsig_config) = dns_config.tsig.as_ref() {
|
let signer = if let Some(tsig_config) = dns_config.tsig.as_ref() {
|
||||||
|
info!("Client configured with TSIG authentication");
|
||||||
Some(Arc::new(TSigner::new(
|
Some(Arc::new(TSigner::new(
|
||||||
tsig_config.key.clone(),
|
tsig_config.key.clone(),
|
||||||
tsig_config.algorithm.clone(),
|
tsig_config.algorithm.clone(),
|
||||||
|
@ -40,6 +42,7 @@ impl DnsClient {
|
||||||
60,
|
60,
|
||||||
)?.into()))
|
)?.into()))
|
||||||
} else {
|
} else {
|
||||||
|
info!("Client configured without authentication");
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -77,4 +80,3 @@ where
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -250,6 +250,7 @@ impl ZoneConnector for DnsConnectorClient {
|
||||||
async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> ConnectorResult<()>
|
async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> ConnectorResult<()>
|
||||||
{
|
{
|
||||||
let response = {
|
let response = {
|
||||||
|
info!("Querying SOA for name {}", zone);
|
||||||
let query = self.client.query(zone.clone(), class, RecordType::SOA);
|
let query = self.client.query(zone.clone(), class, RecordType::SOA);
|
||||||
match query.await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
match query.await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
|
|
|
@ -2,6 +2,7 @@ use std::ops::Deref;
|
||||||
|
|
||||||
|
|
||||||
use rocket::request::FromParam;
|
use rocket::request::FromParam;
|
||||||
|
use rocket::form::{self, FromFormField, ValueField};
|
||||||
use serde::{Deserialize, Serialize, Deserializer, Serializer};
|
use serde::{Deserialize, Serialize, Deserializer, Serializer};
|
||||||
use trust_dns_proto::error::ProtoError;
|
use trust_dns_proto::error::ProtoError;
|
||||||
|
|
||||||
|
@ -48,6 +49,13 @@ impl SerdeName {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_absolute_name(name: &str) -> Result<AbsoluteName, ProtoError> {
|
||||||
|
let mut name = Name::from_utf8(name)?;
|
||||||
|
if !name.is_fqdn() {
|
||||||
|
name.set_fqdn(true);
|
||||||
|
}
|
||||||
|
Ok(AbsoluteName(SerdeName(name)))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AbsoluteName(SerdeName);
|
pub struct AbsoluteName(SerdeName);
|
||||||
|
@ -56,14 +64,22 @@ impl<'r> FromParam<'r> for AbsoluteName {
|
||||||
type Error = ProtoError;
|
type Error = ProtoError;
|
||||||
|
|
||||||
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
|
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
|
||||||
let mut name = Name::from_utf8(¶m)?;
|
let name = parse_absolute_name(param)?;
|
||||||
if !name.is_fqdn() {
|
Ok(name)
|
||||||
name.set_fqdn(true);
|
|
||||||
}
|
|
||||||
Ok(AbsoluteName(SerdeName(name)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'v> FromFormField<'v> for AbsoluteName {
|
||||||
|
fn from_value(field: ValueField<'v>) -> form::Result<'v, Self> {
|
||||||
|
let name = parse_absolute_name(field.value)
|
||||||
|
.map_err(|_| form::Error::validation("Invalid name"))?;
|
||||||
|
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
impl Deref for AbsoluteName {
|
impl Deref for AbsoluteName {
|
||||||
type Target = Name;
|
type Target = Name;
|
||||||
fn deref(&self) -> &Self::Target {
|
fn deref(&self) -> &Self::Target {
|
||||||
|
|
|
@ -22,7 +22,7 @@ pub const COOKIE_NAME: &str = "session_id";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, FromForm)]
|
#[derive(Debug, Deserialize, FromForm)]
|
||||||
pub struct AuthTokenRequest {
|
pub struct AuthTokenRequest {
|
||||||
pub username: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@ pub struct User {
|
||||||
#[primary_key(user_id)]
|
#[primary_key(user_id)]
|
||||||
pub struct LocalUser {
|
pub struct LocalUser {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub username: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub role: Role,
|
pub role: Role,
|
||||||
}
|
}
|
||||||
|
@ -55,13 +55,12 @@ pub struct UserZone {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateUserRequest {
|
pub struct CreateUserRequest {
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub role: Option<Role>
|
pub role: Option<Role>
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UserInfo {
|
pub struct UserInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub role: Role,
|
pub role: Role,
|
||||||
|
@ -159,7 +158,7 @@ impl LocalUser {
|
||||||
|
|
||||||
let new_localuser = LocalUser {
|
let new_localuser = LocalUser {
|
||||||
user_id: new_user_id,
|
user_id: new_user_id,
|
||||||
username: user_request.username.clone(),
|
email: user_request.email.clone(),
|
||||||
password: LocalUser::hash_password(&user_request.password),
|
password: LocalUser::hash_password(&user_request.password),
|
||||||
role: if let Some(user_role) = user_request.role { user_role } else { Role::ZoneAdmin },
|
role: if let Some(user_role) = user_request.role { user_role } else { Role::ZoneAdmin },
|
||||||
};
|
};
|
||||||
|
@ -167,7 +166,7 @@ impl LocalUser {
|
||||||
let res = UserInfo {
|
let res = UserInfo {
|
||||||
id: new_user.id.clone(),
|
id: new_user.id.clone(),
|
||||||
role: new_localuser.role.clone(),
|
role: new_localuser.role.clone(),
|
||||||
username: new_localuser.username.clone(),
|
username: new_localuser.email.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
conn.immediate_transaction(|| -> diesel::QueryResult<()> {
|
conn.immediate_transaction(|| -> diesel::QueryResult<()> {
|
||||||
|
@ -190,7 +189,7 @@ impl LocalUser {
|
||||||
|
|
||||||
pub fn get_user_by_creds(
|
pub fn get_user_by_creds(
|
||||||
conn: &diesel::SqliteConnection,
|
conn: &diesel::SqliteConnection,
|
||||||
request_username: &str,
|
request_email: &str,
|
||||||
request_password: &str
|
request_password: &str
|
||||||
) -> Result<UserInfo, UserError> {
|
) -> Result<UserInfo, UserError> {
|
||||||
|
|
||||||
|
@ -198,7 +197,7 @@ impl LocalUser {
|
||||||
use crate::schema::user::dsl::*;
|
use crate::schema::user::dsl::*;
|
||||||
|
|
||||||
let (client_user, client_localuser): (User, LocalUser) = user.inner_join(localuser)
|
let (client_user, client_localuser): (User, LocalUser) = user.inner_join(localuser)
|
||||||
.filter(username.eq(request_username))
|
.filter(email.eq(request_email))
|
||||||
.get_result(conn)
|
.get_result(conn)
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
DieselError::NotFound => UserError::BadCreds,
|
DieselError::NotFound => UserError::BadCreds,
|
||||||
|
@ -212,7 +211,7 @@ impl LocalUser {
|
||||||
Ok(UserInfo {
|
Ok(UserInfo {
|
||||||
id: client_user.id,
|
id: client_user.id,
|
||||||
role: client_localuser.role,
|
role: client_localuser.role,
|
||||||
username: client_localuser.username,
|
username: client_localuser.email,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,7 +230,7 @@ impl LocalUser {
|
||||||
Ok(UserInfo {
|
Ok(UserInfo {
|
||||||
id: client_user.id,
|
id: client_user.id,
|
||||||
role: client_localuser.role,
|
role: client_localuser.role,
|
||||||
username: client_localuser.username,
|
username: client_localuser.email,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ pub struct AddZoneMemberRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, FromForm)]
|
||||||
pub struct CreateZoneRequest {
|
pub struct CreateZoneRequest {
|
||||||
pub name: AbsoluteName,
|
pub name: AbsoluteName,
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ use crate::DbConn;
|
||||||
use crate::dns::{RecordConnector, ZoneConnector};
|
use crate::dns::{RecordConnector, ZoneConnector};
|
||||||
use crate::models;
|
use crate::models;
|
||||||
use crate::models::{ParseRecordList};
|
use crate::models::{ParseRecordList};
|
||||||
|
use crate::controllers;
|
||||||
|
|
||||||
|
|
||||||
#[get("/zones/<zone>/records")]
|
#[get("/zones/<zone>/records")]
|
||||||
|
@ -130,33 +131,27 @@ pub async fn get_zones(
|
||||||
) -> Result<Json<Vec<models::Zone>>, models::ErrorResponse> {
|
) -> Result<Json<Vec<models::Zone>>, models::ErrorResponse> {
|
||||||
let user_info = user_info?;
|
let user_info = user_info?;
|
||||||
|
|
||||||
let zones = conn.run(move |c| {
|
controllers::get_zones(
|
||||||
if user_info.is_admin() {
|
&conn,
|
||||||
models::Zone::get_all(c)
|
user_info
|
||||||
} else {
|
).await.map(|zones| Json(zones))
|
||||||
user_info.get_zones(c)
|
|
||||||
}
|
|
||||||
}).await?;
|
|
||||||
|
|
||||||
Ok(Json(zones))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/zones", data = "<zone_request>")]
|
#[post("/zones", data = "<zone_request>")]
|
||||||
pub async fn create_zone(
|
pub async fn create_zone(
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
mut dns_api: Box<dyn ZoneConnector>,
|
dns_api: Box<dyn ZoneConnector>,
|
||||||
user_info: Result<models::UserInfo, models::ErrorResponse>,
|
user_info: Result<models::UserInfo, models::ErrorResponse>,
|
||||||
zone_request: Json<models::CreateZoneRequest>,
|
zone_request: Json<models::CreateZoneRequest>,
|
||||||
) -> Result<Json<models::Zone>, models::ErrorResponse> {
|
) -> Result<Json<models::Zone>, models::ErrorResponse> {
|
||||||
user_info?.check_admin()?;
|
let user_info = user_info?;
|
||||||
|
|
||||||
dns_api.zone_exists(zone_request.name.clone(), models::DNSClass::IN.into()).await?;
|
controllers::create_zone(
|
||||||
|
&conn,
|
||||||
let zone = conn.run(move |c| {
|
dns_api,
|
||||||
models::Zone::create_zone(c, zone_request.into_inner())
|
user_info,
|
||||||
}).await?;
|
zone_request.into_inner()
|
||||||
|
).await.map(|zone| Json(zone))
|
||||||
Ok(Json(zone))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,13 @@ use serde_json::{Value, json};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use rocket::http::{Status};
|
use rocket::http::{Status};
|
||||||
use rocket::http::uri::Origin;
|
use rocket::http::uri::Origin;
|
||||||
|
use rocket::form::Form;
|
||||||
|
|
||||||
use crate::template::Template;
|
use crate::template::Template;
|
||||||
use crate::models;
|
use crate::models;
|
||||||
|
use crate::controllers;
|
||||||
use crate::DbConn;
|
use crate::DbConn;
|
||||||
|
use crate::dns::ZoneConnector;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
@ -44,14 +47,10 @@ pub async fn get_zone_records_page(user_info: models::UserInfo, zone: models::Ab
|
||||||
|
|
||||||
#[get("/zones")]
|
#[get("/zones")]
|
||||||
pub async fn get_zones_page(user_info: models::UserInfo, conn: DbConn, origin: &Origin<'_>) -> Result<Template<'static, Value>, Status> {
|
pub async fn get_zones_page(user_info: models::UserInfo, conn: DbConn, origin: &Origin<'_>) -> Result<Template<'static, Value>, Status> {
|
||||||
let zones = conn.run(move |c| {
|
let zones = controllers::get_zones(
|
||||||
if user_info.is_admin() {
|
&conn,
|
||||||
models::Zone::get_all(c)
|
user_info
|
||||||
} else {
|
).await.map_err(|e| e.status)?;
|
||||||
user_info.get_zones(c)
|
|
||||||
}
|
|
||||||
}).await.map_err(|e| models::ErrorResponse::from(e).status)?;
|
|
||||||
|
|
||||||
|
|
||||||
Ok(Template::new(
|
Ok(Template::new(
|
||||||
"pages/zones.html",
|
"pages/zones.html",
|
||||||
|
@ -62,3 +61,68 @@ pub async fn get_zones_page(user_info: models::UserInfo, conn: DbConn, origin: &
|
||||||
})
|
})
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[get("/zones/new")]
|
||||||
|
pub async fn get_create_zone_page(
|
||||||
|
conn: DbConn,
|
||||||
|
user_info: models::UserInfo,
|
||||||
|
origin: &Origin<'_>
|
||||||
|
) -> Result<Template<'static, Value>, Status> {
|
||||||
|
|
||||||
|
user_info
|
||||||
|
.check_admin()
|
||||||
|
.map_err(|e| models::ErrorResponse::from(e).status)?;
|
||||||
|
|
||||||
|
let zones = controllers::get_zones(
|
||||||
|
&conn,
|
||||||
|
user_info
|
||||||
|
).await.map_err(|e| e.status)?;
|
||||||
|
|
||||||
|
Ok(Template::new(
|
||||||
|
"pages/zones/new.html",
|
||||||
|
json!({
|
||||||
|
"zone": None::<models::Zone>,
|
||||||
|
"zones": zones,
|
||||||
|
"error": None::<String>,
|
||||||
|
"nav_page": origin.clone().into_normalized().path().as_str(),
|
||||||
|
"nav_sections": vec!["zones", "_new-zone"],
|
||||||
|
})
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/zones/new", data = "<zone_request>")]
|
||||||
|
pub async fn post_create_zone_page(
|
||||||
|
conn: DbConn,
|
||||||
|
dns_api: Box<dyn ZoneConnector>,
|
||||||
|
user_info: models::UserInfo,
|
||||||
|
zone_request: Form<models::CreateZoneRequest>,
|
||||||
|
origin: &Origin<'_>
|
||||||
|
) -> Result<Template<'static, Value>, Status> {
|
||||||
|
user_info
|
||||||
|
.check_admin()
|
||||||
|
.map_err(|e| models::ErrorResponse::from(e).status)?;
|
||||||
|
|
||||||
|
let zone = controllers::create_zone(
|
||||||
|
&conn,
|
||||||
|
dns_api,
|
||||||
|
user_info.clone(),
|
||||||
|
zone_request.into_inner()
|
||||||
|
).await.map_err(|e| e.status)?;
|
||||||
|
|
||||||
|
let zones = controllers::get_zones(
|
||||||
|
&conn,
|
||||||
|
user_info
|
||||||
|
).await.map_err(|e| e.status)?;
|
||||||
|
|
||||||
|
Ok(Template::new(
|
||||||
|
"pages/zones/new.html",
|
||||||
|
json!({
|
||||||
|
"zone": Some(zone),
|
||||||
|
"zones": zones,
|
||||||
|
"error": None::<String>,
|
||||||
|
"nav_page": origin.clone().into_normalized().path().as_str(),
|
||||||
|
"nav_sections": vec!["zones", "_new-zone"],
|
||||||
|
})
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ table! {
|
||||||
|
|
||||||
localuser (user_id) {
|
localuser (user_id) {
|
||||||
user_id -> Text,
|
user_id -> Text,
|
||||||
username -> Text,
|
email -> Text,
|
||||||
password -> Text,
|
password -> Text,
|
||||||
role -> RoleMapping,
|
role -> RoleMapping,
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,33 +2,43 @@
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav aria-label="Principal">
|
<nav aria-label="Principal" class="main">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/profil">Mon profile</a></li>
|
<li><a href="/profile">Mon profil</a></li>
|
||||||
<li>
|
<li>
|
||||||
{{ macros::nav_link(
|
{{ macros::nav_link(
|
||||||
content="Mes zones",
|
content="Mes zones",
|
||||||
href="/zones",
|
href="/zones",
|
||||||
current_page=nav_page,
|
current_page=nav_page,
|
||||||
section="zones",
|
section="zones",
|
||||||
current_sections=nav_sections,
|
current_sections=nav_sections,
|
||||||
) }}
|
) }}
|
||||||
<ul>
|
<ul>
|
||||||
{% for zone in zones %}
|
{% for zone in zones %}
|
||||||
<li>
|
<li>
|
||||||
{{ macros::nav_link(
|
{{ macros::nav_link(
|
||||||
content=zone.name,
|
content=zone.name,
|
||||||
href="/zone/" ~ zone.name,
|
href="/zone/" ~ zone.name,
|
||||||
current_page=nav_page,
|
current_page=nav_page,
|
||||||
section=zone.name,
|
section=zone.name,
|
||||||
current_sections=nav_sections,
|
current_sections=nav_sections,
|
||||||
) }}
|
) }}
|
||||||
{% endfor %}
|
</li>
|
||||||
</ul>
|
{% endfor %}
|
||||||
</li>
|
<li>
|
||||||
</ul>
|
{{ macros::nav_link(
|
||||||
</nav>
|
safe_content='<img alt="" src="/images/plus.svg"> Ajouter une zone',
|
||||||
|
href="/zones/new",
|
||||||
|
current_page=nav_page,
|
||||||
|
section="_new-zone",
|
||||||
|
current_sections=nav_sections,
|
||||||
|
) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
{% block main %}{% endblock main %}
|
{% block main %}{% endblock main %}
|
||||||
</main>
|
</main>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}{% endblock title %}Nomilo</title>
|
<title>{% block title %}{% endblock title %}Nomilo</title>
|
||||||
<link rel="stylesheet" type="text/css" href="/styles/main.css">
|
<link rel="stylesheet" type="text/css" href="/styles/main.css">
|
||||||
|
{% block styles %}{% endblock styles %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{% macro nav_link(content, href, current_page, section=False, current_sections=False, props='') %}
|
{% macro nav_link(content, href, current_page, content='', safe_content='', section=False, current_sections=False, props='') %}
|
||||||
<a
|
<a
|
||||||
href="{{ href }}"
|
href="{{ href }}"
|
||||||
{{ props }}
|
{{ props }}
|
||||||
|
@ -7,5 +7,5 @@
|
||||||
{% elif section and section in current_sections %}
|
{% elif section and section in current_sections %}
|
||||||
aria-current="location"
|
aria-current="location"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
>{{ content }}</a>
|
>{{ content }}{{ safe_content | safe }}</a>
|
||||||
{% endmacro nav_link %}
|
{% endmacro nav_link %}
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
{% extends "bases/base.html" %}
|
{% extends "bases/base.html" %}
|
||||||
|
|
||||||
{% block title %}Login ⋅ {% endblock title %}
|
{% block title %}Se connecter ⋅ {% endblock title %}
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="/styles/login.css">
|
||||||
|
{% endblock styles %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<main>
|
||||||
{% if error %}
|
|
||||||
<p>
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<form method="POST" action="/login">
|
<form method="POST" action="/login">
|
||||||
<input type="text" name="username">
|
<h1>Se connecter</h1>
|
||||||
<input type="password" name="password">
|
{% if error %}
|
||||||
|
<p class="feedback error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<label for="email">Adresse e-mail</label>
|
||||||
|
<input type="email" id="email" name="email">
|
||||||
|
<label for="password">Mot de passe</label>
|
||||||
|
<input type="password" id="password" name="password">
|
||||||
<input type="submit" value="Se connecter">
|
<input type="submit" value="Se connecter">
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -2,10 +2,13 @@
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
|
||||||
{% block title %}{{ current_zone }} ⋅ Records ⋅ {% endblock title %}
|
{% block title %}{{ current_zone }} ⋅ Records ⋅ {% endblock title %}
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="/styles/zone.css">
|
||||||
|
{% endblock styles %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<h1>Gestion de la zone {{ current_zone }}</h1>
|
<h1>Gestion de la zone {{ current_zone }}</h1>
|
||||||
<nav aria-label="Secondaire">
|
<nav class="secondary" aria-label="Secondaire">
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
{{ macros::nav_link(
|
{{ macros::nav_link(
|
||||||
|
@ -24,13 +27,9 @@
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<section>
|
<section>
|
||||||
<h2>Enregistrements</h2>
|
|
||||||
<zone-content>
|
<zone-content>
|
||||||
</zone-content>
|
</zone-content>
|
||||||
</section>
|
</section>
|
||||||
<aside>
|
|
||||||
<h2>Aide</h2>
|
|
||||||
</aside>
|
|
||||||
{% endblock main %}
|
{% endblock main %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|
18
templates/pages/zones/new.html
Normal file
18
templates/pages/zones/new.html
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{% extends "bases/app.html" %}
|
||||||
|
|
||||||
|
{% block title %}Ajouter une zone ⋅ {% endblock title %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<h1>Ajouter une zone</h1>
|
||||||
|
<form method="POST" action="/zones/new">
|
||||||
|
{% if error %}
|
||||||
|
<p class="feedback error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<label for="zone_name">Nom de la zone</label>
|
||||||
|
<input type="text" id="zone_name" name="name">
|
||||||
|
<input type="submit" value="Créer la zone">
|
||||||
|
</form>
|
||||||
|
{% endblock main %}
|
Loading…
Reference in a new issue