ezidam: apps: view, update, new secret
This commit is contained in:
parent
4b99905ee0
commit
2caf584cb7
12 changed files with 407 additions and 9 deletions
|
|
@ -78,4 +78,29 @@ impl App {
|
|||
.await?
|
||||
.map(Self::from))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
conn: impl SqliteExecutor<'_>,
|
||||
id: &AppID,
|
||||
label: &str,
|
||||
redirect_uri: &Url,
|
||||
is_confidential: bool,
|
||||
) -> Result<Option<()>, Error> {
|
||||
Ok(DatabaseApps::update(
|
||||
conn,
|
||||
id.as_ref(),
|
||||
label,
|
||||
redirect_uri.as_str(),
|
||||
is_confidential,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn new_secret(
|
||||
&self,
|
||||
conn: impl SqliteExecutor<'_>,
|
||||
secret: &Secret,
|
||||
) -> Result<Option<()>, Error> {
|
||||
Ok(DatabaseApps::new_secret(conn, self.id.as_ref(), secret.hash()).await?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
crates/database/queries/apps/new_secret.sql
Normal file
5
crates/database/queries/apps/new_secret.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
update apps
|
||||
|
||||
set secret = ?
|
||||
|
||||
where id is ?
|
||||
7
crates/database/queries/apps/update.sql
Normal file
7
crates/database/queries/apps/update.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
update apps
|
||||
|
||||
set label = ?,
|
||||
redirect_uri = ?,
|
||||
is_confidential = ?
|
||||
|
||||
where id is ?
|
||||
|
|
@ -80,6 +80,26 @@
|
|||
},
|
||||
"query": "select id,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n label,\n redirect_uri,\n secret,\n is_confidential as \"is_confidential: bool\",\n is_archived as \"is_archived: bool\"\nfrom apps\n\nwhere is_archived is 0\norder by created_at desc"
|
||||
},
|
||||
"184d704e75f00513082dd2c6cc3ae5c3f58b57b222ba4333216b5c50c3c58c71": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"query": "update apps\n\nset label = ?,\n redirect_uri = ?,\n is_confidential = ?\n\nwhere id is ?"
|
||||
},
|
||||
"1e2edc8cf28832344dbfa0878ac01361b6f97c552d6af8477da12cddb03d4865": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
}
|
||||
},
|
||||
"query": "update apps\n\nset secret = ?\n\nwhere id is ?"
|
||||
},
|
||||
"3c8e31ffa5cbfd4dded8a272777cb320fb51fd2e53ed25054d24e9801df0c358": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
|
|
|
|||
|
|
@ -102,4 +102,38 @@ impl Apps {
|
|||
.await
|
||||
.map_err(handle_error)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
conn: impl SqliteExecutor<'_>,
|
||||
id: &str,
|
||||
label: &str,
|
||||
redirect_uri: &str,
|
||||
is_confidential: bool,
|
||||
) -> Result<Option<()>, Error> {
|
||||
let query: SqliteQueryResult = sqlx::query_file!(
|
||||
"queries/apps/update.sql",
|
||||
label,
|
||||
redirect_uri,
|
||||
is_confidential,
|
||||
id
|
||||
)
|
||||
.execute(conn)
|
||||
.await
|
||||
.map_err(handle_error)?;
|
||||
|
||||
Ok((query.rows_affected() == 1).then_some(()))
|
||||
}
|
||||
|
||||
pub async fn new_secret(
|
||||
conn: impl SqliteExecutor<'_>,
|
||||
id: &str,
|
||||
secret: &str,
|
||||
) -> Result<Option<()>, Error> {
|
||||
let query: SqliteQueryResult = sqlx::query_file!("queries/apps/new_secret.sql", secret, id)
|
||||
.execute(conn)
|
||||
.await
|
||||
.map_err(handle_error)?;
|
||||
|
||||
Ok((query.rows_affected() == 1).then_some(()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
use id::UserID;
|
||||
use id::{AppID, Error, UserID};
|
||||
use rocket::http::impl_from_uri_param_identity;
|
||||
use rocket::http::uri::fmt::{Formatter, Path, UriDisplay};
|
||||
use rocket::request::FromParam;
|
||||
use rocket::serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -8,9 +11,32 @@ use std::str::FromStr;
|
|||
pub struct RocketUserID(pub UserID);
|
||||
|
||||
impl<'r> FromParam<'r> for RocketUserID {
|
||||
type Error = id::Error;
|
||||
type Error = Error;
|
||||
|
||||
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
|
||||
UserID::from_str(param).map(RocketUserID)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct RocketAppID(pub AppID);
|
||||
|
||||
impl<'r> FromParam<'r> for RocketAppID {
|
||||
type Error = Error;
|
||||
|
||||
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
|
||||
if param == "ezidam" {
|
||||
return Err(Error::Invalid("App"));
|
||||
}
|
||||
AppID::from_str(param).map(RocketAppID)
|
||||
}
|
||||
}
|
||||
|
||||
impl UriDisplay<Path> for RocketAppID {
|
||||
fn fmt(&self, f: &mut Formatter<Path>) -> fmt::Result {
|
||||
UriDisplay::fmt(&self.0 .0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl_from_uri_param_identity!([Path] RocketAppID);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub enum Page {
|
|||
AdminSettingsSecurity(AdminSettingsSecurity),
|
||||
AdminAppsList(AdminAppsList),
|
||||
AdminAppsNew(AdminAppsNew),
|
||||
AdminAppsView(AdminAppsView),
|
||||
}
|
||||
|
||||
impl Page {
|
||||
|
|
@ -36,6 +37,7 @@ impl Page {
|
|||
Page::AdminSettingsSecurity(_) => "pages/admin/settings/security",
|
||||
Page::AdminAppsList(_) => "pages/admin/apps/list",
|
||||
Page::AdminAppsNew(_) => "pages/admin/apps/new",
|
||||
Page::AdminAppsView(_) => "pages/admin/apps/view",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +54,7 @@ impl Page {
|
|||
Page::AdminSettingsSecurity(_) => "Server security",
|
||||
Page::AdminAppsList(_) => "Applications",
|
||||
Page::AdminAppsNew(_) => "New application",
|
||||
Page::AdminAppsView(_) => "Application info",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +73,7 @@ impl Page {
|
|||
Page::AdminSettingsSecurity(_) => Some(AdminMenu::Settings.into()),
|
||||
Page::AdminAppsList(_) => Some(AdminMenu::Apps.into()),
|
||||
Page::AdminAppsNew(_) => Some(AdminMenu::Apps.into()),
|
||||
Page::AdminAppsView(_) => Some(AdminMenu::Apps.into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +90,7 @@ impl Page {
|
|||
Page::AdminSettingsSecurity(security) => Box::new(security),
|
||||
Page::AdminAppsList(list) => Box::new(list),
|
||||
Page::AdminAppsNew(new) => Box::new(new),
|
||||
Page::AdminAppsView(view) => Box::new(view),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ pub fn routes() -> Vec<Route> {
|
|||
admin_apps_list,
|
||||
admin_apps_new,
|
||||
admin_apps_new_form,
|
||||
admin_apps_view,
|
||||
admin_apps_view_form,
|
||||
admin_apps_new_secret,
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -62,4 +65,12 @@ pub mod content {
|
|||
pub struct AdminAppsNew {
|
||||
pub user: JwtClaims,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
#[derive(Clone)]
|
||||
pub struct AdminAppsView {
|
||||
pub user: JwtClaims,
|
||||
pub app: App,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ pub async fn admin_apps_new(admin: JwtAdmin) -> Result<Page> {
|
|||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct NewApp<'r> {
|
||||
pub struct AppForm<'r> {
|
||||
pub label: &'r str,
|
||||
pub redirect_uri: &'r str,
|
||||
pub is_confidential: bool,
|
||||
|
|
@ -43,7 +43,7 @@ pub struct NewApp<'r> {
|
|||
pub async fn admin_apps_new_form(
|
||||
mut db: Connection<Database>,
|
||||
_admin: JwtAdmin,
|
||||
form: Form<NewApp<'_>>,
|
||||
form: Form<AppForm<'_>>,
|
||||
) -> Result<Flash<Redirect>> {
|
||||
// Generate app id
|
||||
let app_id = task::spawn_blocking(AppID::default).await?;
|
||||
|
|
@ -73,3 +73,96 @@ pub async fn admin_apps_new_form(
|
|||
format!("Application \"{}\" has been created", form.label),
|
||||
))
|
||||
}
|
||||
|
||||
#[get("/admin/apps/<id>")]
|
||||
pub async fn admin_apps_view(
|
||||
mut db: Connection<Database>,
|
||||
id: RocketAppID,
|
||||
admin: JwtAdmin,
|
||||
flash: Option<FlashMessage<'_>>,
|
||||
) -> Result<Template> {
|
||||
let app_id = id.0;
|
||||
let app = App::get_one_by_id(&mut *db, app_id.as_ref())
|
||||
.await?
|
||||
.ok_or_else(|| Error::not_found(app_id.to_string()))?;
|
||||
|
||||
let page = Page::AdminAppsView(super::content::AdminAppsView { user: admin.0, app });
|
||||
|
||||
Ok(flash
|
||||
.map(|flash| Page::with_flash(page.clone(), flash))
|
||||
.unwrap_or_else(|| page.into()))
|
||||
}
|
||||
|
||||
#[post("/admin/apps/<id>", data = "<form>")]
|
||||
pub async fn admin_apps_view_form(
|
||||
mut db: Connection<Database>,
|
||||
id: RocketAppID,
|
||||
_admin: JwtAdmin,
|
||||
form: Form<AppForm<'_>>,
|
||||
) -> Result<Flash<Redirect>> {
|
||||
// Parse redirect uri
|
||||
let redirect_uri = Url::parse(form.redirect_uri)?;
|
||||
|
||||
// Update app
|
||||
App::update(
|
||||
&mut *db,
|
||||
&id.0,
|
||||
form.label,
|
||||
&redirect_uri,
|
||||
form.is_confidential,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Flash::new(
|
||||
Redirect::to(uri!(admin_apps_list)),
|
||||
FlashKind::Success,
|
||||
format!("Application \"{}\" has been updated", form.label),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct NewSecretForm {
|
||||
pub regenerate_secret: bool,
|
||||
}
|
||||
|
||||
#[post("/admin/apps/<id>/secret", data = "<form>")]
|
||||
pub async fn admin_apps_new_secret(
|
||||
mut db: Connection<Database>,
|
||||
id: RocketAppID,
|
||||
_admin: JwtAdmin,
|
||||
form: Form<NewSecretForm>,
|
||||
) -> Result<Flash<Redirect>> {
|
||||
if !form.regenerate_secret {
|
||||
return Err(Error::bad_request("Not generating a new secret"));
|
||||
}
|
||||
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
// Get app
|
||||
let app_id = &id.0;
|
||||
let app = App::get_one_by_id(&mut transaction, app_id.as_ref())
|
||||
.await?
|
||||
.ok_or_else(|| Error::not_found(app_id.to_string()))?;
|
||||
|
||||
// Generate secret
|
||||
let app_secret = task::spawn_blocking(SecretString::default).await?;
|
||||
let app_secret_string = app_secret.to_string();
|
||||
|
||||
// Hash secret
|
||||
let app_secret_hash = task::spawn_blocking(move || Secret::new(app_secret)).await??;
|
||||
|
||||
// Save new secret
|
||||
app.new_secret(&mut transaction, &app_secret_hash).await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(Flash::new(
|
||||
Redirect::to(uri!(admin_apps_view(id))),
|
||||
FlashKind::Success,
|
||||
format!(
|
||||
"Secret for application \"{}\" has been updated. It will only be shown once!\
|
||||
<div class=\"mt-1 user-select-all\">{app_secret_string}</div>",
|
||||
app.label(),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@
|
|||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table-tbody">
|
||||
|
||||
<!-- Table content -->
|
||||
<tbody class="table-tbody">
|
||||
{% for app in apps %}
|
||||
<tr>
|
||||
<td class="sort-name">{{ app.label }}</td>
|
||||
|
|
@ -81,18 +81,18 @@
|
|||
<span class="badge bg-success me-1"></span> Active
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="sort-creation-date" data-date="{{ app.created_at | date(timestamp=true) }}">
|
||||
<td class="sort-creation-date" data-date='{{ app.created_at | date(format="%s") }}'>
|
||||
{{ app.created_at | date() }}
|
||||
</td>
|
||||
<td class="sort-id">{{ app.id }}</td>
|
||||
<td class="sort-redirect-uri">{{ app.redirect_uri }}</td>
|
||||
<td>
|
||||
<a href="#">Details</a>
|
||||
<a href="apps/{{ app.id }}">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -127,7 +127,7 @@
|
|||
sortClass: 'table-sort',
|
||||
listClass: 'table-tbody',
|
||||
valueNames: ['sort-name', 'sort-status',
|
||||
{attr: 'data-date', name: 'sort-date'},
|
||||
{attr: 'data-date', name: 'sort-creation-date'},
|
||||
'sort-id', 'sort-redirect-uri',
|
||||
]
|
||||
});
|
||||
|
|
|
|||
166
crates/ezidam/templates/pages/admin/apps/view.html.tera
Normal file
166
crates/ezidam/templates/pages/admin/apps/view.html.tera
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
{% extends "shell" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-print-none">
|
||||
<div class="container-xl">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">
|
||||
Admin dashboard
|
||||
</div>
|
||||
<h2 class="page-title">
|
||||
Applications
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Page body -->
|
||||
<div class="page-body">
|
||||
<div class="container-xl">
|
||||
|
||||
{% if flash %}
|
||||
<div class="alert alert-{{flash.0}}" role="alert">
|
||||
<h4 class="alert-title">{{ flash.1 | safe }}</h4>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Application IDs</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<!-- App id -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">App ID</label>
|
||||
<input type="text" value="{{ app.id }}" class="form-control" readonly>
|
||||
</div>
|
||||
|
||||
<!-- App secret -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">App secret</label>
|
||||
<input type="password" class="form-control" value="super_secure_useless_app_secret" readonly>
|
||||
</div>
|
||||
|
||||
<!-- Regenerate secret -->
|
||||
<div>
|
||||
<form action="{{ app.id }}/secret" method="post">
|
||||
<div class="mb-2">Lost your secret?</div>
|
||||
<button type="submit" name="regenerate_secret" value="true" class="btn btn-danger">
|
||||
Regenerate secret
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Edit Application</h3>
|
||||
</div>
|
||||
|
||||
<form action="" method="post">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- App name -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required" for="label">Application name</label>
|
||||
<div>
|
||||
<input name="label" id="label" type="text" value="{{ app.label }}" placeholder="Enter name"
|
||||
class="form-control"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redirect URI -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required" for="redirect_uri">Redirect URI</label>
|
||||
<div>
|
||||
<input name="redirect_uri" id="redirect_uri" type="url" value="{{ app.redirect_uri }}"
|
||||
placeholder="Enter redirect URI"
|
||||
class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confidential app -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Confidentiality</label>
|
||||
<div class="form-selectgroup-boxes row mb-3">
|
||||
|
||||
<!-- Private apps -->
|
||||
<div class="col-lg-6">
|
||||
<label class="form-selectgroup-item">
|
||||
{% if app.is_confidential == true %}
|
||||
<input name="is_confidential" type="radio" value="true"
|
||||
class="form-selectgroup-input"
|
||||
required checked>
|
||||
{% else %}
|
||||
<input name="is_confidential" type="radio" value="true"
|
||||
class="form-selectgroup-input"
|
||||
required>
|
||||
{% endif %}
|
||||
<span class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<span class="me-3">
|
||||
<span class="form-selectgroup-check"></span>
|
||||
</span>
|
||||
|
||||
<span class="form-selectgroup-label-content">
|
||||
<span class="form-selectgroup-title strong mb-1">Private application</span>
|
||||
<span class="d-block text-muted">
|
||||
For applications with a backend, where the application secret can be securely stored.
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Public apps -->
|
||||
<div class="col-lg-6">
|
||||
<label class="form-selectgroup-item">
|
||||
{% if app.is_confidential == false %}
|
||||
<input name="is_confidential" type="radio" value="false"
|
||||
class="form-selectgroup-input"
|
||||
required checked>
|
||||
{% else %}
|
||||
<input name="is_confidential" type="radio" value="false"
|
||||
class="form-selectgroup-input"
|
||||
required>
|
||||
{% endif %}
|
||||
<span class="form-selectgroup-label d-flex align-items-center p-3">
|
||||
<span class="me-3">
|
||||
<span class="form-selectgroup-check"></span>
|
||||
</span>
|
||||
|
||||
<span class="form-selectgroup-label-content">
|
||||
<span class="form-selectgroup-title strong mb-1">Public application</span>
|
||||
<span class="d-block text-muted">
|
||||
For public applications (PWAs, desktop, mobile). The application secret can not be securely stored.
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-end">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block libs_js %}
|
||||
{% endblock lib_js %}
|
||||
|
||||
{% block additional_js %}
|
||||
{% endblock additional_js %}
|
||||
|
|
@ -2,6 +2,7 @@ use crate::error::Error;
|
|||
use crate::hash::{hash, Hash};
|
||||
use nanoid::nanoid;
|
||||
use nanoid_dictionary::ALPHANUMERIC;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
// Struct to generate the secret
|
||||
pub struct SecretString(String);
|
||||
|
|
@ -20,6 +21,11 @@ impl AsRef<str> for SecretString {
|
|||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
impl Display for SecretString {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Secret(Hash);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue