Compare commits

...

2 commits

Author SHA1 Message Date
Brent Schroeter
341e02a41b reorganize askama templates 2026-01-19 18:48:20 +00:00
Brent Schroeter
9bee5fdaef remove disused "forms" feature 2026-01-19 17:31:59 +00:00
42 changed files with 449 additions and 1685 deletions

View file

@ -0,0 +1,34 @@
create table if not exists form_transitions (
id uuid not null primary key default uuidv7(),
source_id uuid not null references portals(id) on delete cascade,
dest_id uuid not null references portals(id) on delete restrict,
condition jsonb not null default 'null'
);
create index on form_transitions (source_id);
create table if not exists field_form_prompts (
id uuid not null primary key default uuidv7(),
field_id uuid not null references fields(id) on delete cascade,
language text not null,
content text not null default '',
unique (field_id, language)
);
create index on field_form_prompts (field_id);
create table if not exists form_sessions (
id uuid not null primary key default uuidv7(),
user_id uuid references users(id) on delete cascade
);
create table if not exists form_touch_points (
id uuid not null primary key default uuidv7(),
-- `on delete restrict` errs on the side of conservatism, but is not known
-- to be crucial.
form_session_id uuid not null references form_sessions(id) on delete restrict,
-- `on delete restrict` errs on the side of conservatism, but is not known
-- to be crucial.
portal_id uuid not null references portals(id) on delete restrict,
-- Points to a row in the portal's backing table, so foreign key constraints
-- do not apply here.
row_id uuid not null
);

View file

@ -0,0 +1,3 @@
drop table if exists form_touch_points;
drop table if exists field_form_prompts;
drop table if exists form_transitions;

View file

@ -1,131 +0,0 @@
use derive_builder::Builder;
use serde::{Deserialize, Serialize};
use sqlx::query_as;
use uuid::Uuid;
use crate::{client::AppDbClient, language::Language};
/// A localized prompt to display above or alongside the form input for the
/// given field.
///
/// There may be zero or one `field_form_prompt` entries for each
/// `(field_id, language)` pair. (This uniqueness should be enforced by the
/// database.)
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FieldFormPrompt {
/// Primary key (defaults to UUIDv7).
pub id: Uuid,
/// ID of the field to which this prompt belongs.
pub field_id: Uuid,
/// [ISO 639-3](https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes)
/// language code.
pub language: Language,
/// Prompt content for this field, in this language.
pub content: String,
}
impl FieldFormPrompt {
/// Build an insert statement to create a new prompt.
pub fn upsert() -> UpsertBuilder {
UpsertBuilder::default()
}
/// Build an update statement to alter the content of an existing prompt.
pub fn update() -> UpdateBuilder {
UpdateBuilder::default()
}
/// Build a single-field query by field ID.
pub fn belonging_to_field(id: Uuid) -> BelongingToFieldQuery {
BelongingToFieldQuery { id }
}
}
#[derive(Builder, Clone, Debug)]
pub struct Upsert {
field_id: Uuid,
language: Language,
content: String,
}
impl Upsert {
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<FieldFormPrompt, sqlx::Error> {
query_as!(
FieldFormPrompt,
r#"
insert into field_form_prompts (field_id, language, content) values ($1, $2, $3)
on conflict (field_id, language) do update set
content = excluded.content
returning
id,
field_id,
language as "language: Language",
content
"#,
self.field_id,
self.language.to_string(),
self.content,
)
.fetch_one(app_db.get_conn())
.await
}
}
#[derive(Builder, Clone, Debug, Default)]
pub struct Update {
id: Uuid,
content: String,
}
impl Update {
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<FieldFormPrompt, sqlx::Error> {
query_as!(
FieldFormPrompt,
r#"
update field_form_prompts
set content = $1
where id = $2
returning
id,
field_id,
language as "language: Language",
content
"#,
self.content,
self.id,
)
.fetch_one(app_db.get_conn())
.await
}
}
#[derive(Clone, Debug)]
pub struct BelongingToFieldQuery {
id: Uuid,
}
impl BelongingToFieldQuery {
pub async fn fetch_all(
self,
app_db: &mut AppDbClient,
) -> Result<Vec<FieldFormPrompt>, sqlx::Error> {
query_as!(
FieldFormPrompt,
r#"
select
id,
field_id,
language as "language: Language",
content
from field_form_prompts
where field_id = $1
"#,
self.id,
)
.fetch_all(app_db.get_conn())
.await
}
}

View file

@ -1,162 +0,0 @@
use derive_builder::Builder;
use serde::Serialize;
use sqlx::{Row as _, postgres::PgRow, query, query_as, types::Json};
use uuid::Uuid;
use crate::{client::AppDbClient, expression::PgExpressionAny};
/// A form transition directionally connects two portals within the same
/// workspace, representing a potential navigation of a user between two forms.
/// If the user submits a form, form transitions with `source_id` corresponding
/// to that portal will be evaluated one by one (in order by ID---that is, by
/// creation time), and the first with a condition evaluating to true will be
/// used to direct the user to the form corresponding to portal `dest_id`.
#[derive(Clone, Debug, Serialize)]
pub struct FormTransition {
/// Primary key (defaults to UUIDv7).
pub id: Uuid,
/// When a user is filling out a sequence of forms, this is the ID of the
/// portal for which they have just submitted a form for.
///
/// **Source portal is expected to belong to the same workspace as the
/// destination portal.**
pub source_id: Uuid,
/// When a user is filling out a sequence of forms, this is the ID of the
/// portal for which they will be directed to if the condition evaluates to
/// true.
///
/// **Destination portal is expected to belong to the same workspace as the
/// source portal.**
pub dest_id: Uuid,
/// Represents a semi-arbitrary Postgres expression which will permit this
/// transition to be followed, only if the expression evaluates to true at
/// the time of the source form's submission.
pub condition: Json<Option<PgExpressionAny>>,
}
impl FormTransition {
/// Build a multi-row update statement to replace all transtitions for a
/// given source portal.
pub fn replace_for_portal(portal_id: Uuid) -> ReplaceBuilder {
ReplaceBuilder {
portal_id: Some(portal_id),
..ReplaceBuilder::default()
}
}
/// Build a single-field query by source portal ID.
pub fn with_source(id: Uuid) -> WithSourceQuery {
WithSourceQuery { id }
}
}
#[derive(Clone, Copy, Debug)]
pub struct WithSourceQuery {
id: Uuid,
}
impl WithSourceQuery {
pub async fn fetch_all(
self,
app_db: &mut AppDbClient,
) -> Result<Vec<FormTransition>, sqlx::Error> {
query_as!(
FormTransition,
r#"
select
id,
source_id,
dest_id,
condition as "condition: Json<Option<PgExpressionAny>>"
from form_transitions
where source_id = $1
"#,
self.id,
)
.fetch_all(app_db.get_conn())
.await
}
}
#[derive(Builder, Clone, Debug)]
pub struct Replacement {
pub dest_id: Uuid,
pub condition: Option<PgExpressionAny>,
}
#[derive(Builder, Clone, Debug)]
pub struct Replace {
#[builder(setter(custom))]
portal_id: Uuid,
replacements: Vec<Replacement>,
}
impl Replace {
/// Insert zero or more form transitions, and then remove all others
/// associated with the same portal. When they are being used, form
/// transitions are evaluated from first to last by ID (that is, by
/// creation timestamp, because IDs are UUIDv7s), so none of the newly added
/// transitions will supersede their predecessors until the latter are
/// deleted in one fell swoop. However, there will be a (hopefully brief)
/// period during which both the old and the new transitions will be
/// evaluated together in order, so this is not quite equivalent to an
/// atomic update.
///
/// FIXME: There is a race condition in which executing [`Replace::execute`]
/// for the same portal two or more times simultaneously may remove *all*
/// form transitions for that portal, new and old. This would require
/// impeccable timing, but it should absolutely be fixed... at some point.
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> {
let ids: Vec<Uuid> = if self.replacements.is_empty() {
vec![]
} else {
// Nice to do this in one query to avoid generating even more
// intermediate database state purgatory. Credit to [@greglearns](
// https://github.com/launchbadge/sqlx/issues/294#issuecomment-716149160
// ) for the clever syntax. Too bad it doesn't seem plausible to
// dovetail this with the [`query!`] macro, but that makes sense
// given the circuitous query structure.
query(
r#"
insert into form_transitions (source_id, dest_id, condition)
select * from unnest($1, $2, $3)
returning id
"#,
)
.bind(
self.replacements
.iter()
.map(|_| self.portal_id)
.collect::<Vec<_>>(),
)
.bind(
self.replacements
.iter()
.map(|value| value.dest_id)
.collect::<Vec<_>>(),
)
.bind(
self.replacements
.iter()
.map(|value| Json(value.condition.clone()))
.collect::<Vec<_>>() as Vec<Json<Option<PgExpressionAny>>>,
)
.map(|row: PgRow| -> Uuid { row.get(0) })
.fetch_all(app_db.get_conn())
.await?
};
query!(
"delete from form_transitions where id <> any($1)",
ids.as_slice(),
)
.execute(app_db.get_conn())
.await?;
Ok(())
}
}

View file

@ -23,8 +23,6 @@ pub mod datum;
pub mod errors; pub mod errors;
pub mod expression; pub mod expression;
pub mod field; pub mod field;
pub mod field_form_prompt;
pub mod form_transition;
pub mod language; pub mod language;
mod macros; mod macros;
pub mod portal; pub mod portal;

View file

@ -46,7 +46,7 @@ pub enum RelPermissionKind {
} }
#[derive(Clone, Debug, Eq, Hash, PartialEq, Template)] #[derive(Clone, Debug, Eq, Hash, PartialEq, Template)]
#[template(path = "rel_permission.html")] #[template(path = "includes/rel_permission.html")]
pub(crate) struct RelPermission { pub(crate) struct RelPermission {
pub(crate) kind: RelPermissionKind, pub(crate) kind: RelPermissionKind,
pub(crate) rel_oid: Oid, pub(crate) rel_oid: Oid,
@ -150,7 +150,7 @@ impl TryFrom<Grantee> for User {
/// an "Edit" button which opens a dialog with a form allowing permissions to be /// an "Edit" button which opens a dialog with a form allowing permissions to be
/// assigned across multiple tables within a workspace. /// assigned across multiple tables within a workspace.
#[derive(Clone, Debug, Template)] #[derive(Clone, Debug, Template)]
#[template(path = "workspaces_single/permissions_editor.html")] #[template(path = "includes/permissions_editor.html")]
pub(crate) struct PermissionsEditor { pub(crate) struct PermissionsEditor {
/// User, invite, or service credential being granted permissions. /// User, invite, or service credential being granted permissions.
pub(crate) target: Grantee, pub(crate) target: Grantee,

View file

@ -1,121 +0,0 @@
use std::collections::HashMap;
use askama::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse as _, Response},
};
use phono_backends::pg_attribute::PgAttribute;
use phono_models::{
field::Field,
field_form_prompt::FieldFormPrompt,
language::Language,
portal::Portal,
presentation::{Presentation, TextInputMode},
};
use serde::Deserialize;
use uuid::Uuid;
use crate::{
Settings,
app::AppDbConn,
errors::{AppError, not_found},
field_info::FormFieldInfo,
workspace_pooler::{RoleAssignment, WorkspacePooler},
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
portal_id: Uuid,
}
/// HTTP GET handler for public-facing survey interface. This allows form
/// responses to be collected as rows directly into a Phonograph table.
pub(super) async fn get(
State(settings): State<Settings>,
State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams { portal_id }): Path<PathParams>,
) -> Result<Response, AppError> {
// FIXME: Disallow access unless form has been explicitly marked as public.
// WARNING: Form handler bypasses standard auth checks.
let portal = Portal::with_id(portal_id)
.fetch_optional(&mut app_db)
.await?
.ok_or(not_found!("form not found"))?;
// WARNING: This client is connected with full workspace privileges. Even
// more so than usual, the Phonograph server is responsible for ensuring all
// auth checks are performed properly.
//
// TODO: Can this be delegated to a dedicated and less privileged role
// instead?
let mut workspace_client = pooler
.acquire_for(portal.workspace_id, RoleAssignment::Root)
.await?;
let attrs: HashMap<String, PgAttribute> = PgAttribute::all_for_rel(portal.class_oid)
.fetch_all(&mut workspace_client)
.await?
.into_iter()
.map(|value| (value.attname.clone(), value))
.collect();
// TODO: implement with sql join
let mut fields: Vec<FormFieldInfo> = vec![];
for field in Field::belonging_to_portal(portal_id)
.fetch_all(&mut app_db)
.await?
{
let attr = attrs.get(&field.name);
let prompts: HashMap<Language, String> = FieldFormPrompt::belonging_to_field(field.id)
.fetch_all(&mut app_db)
.await?
.into_iter()
.map(|prompt| (prompt.language, prompt.content))
.collect();
fields.push(FormFieldInfo {
field,
column_present: attr.is_some(),
has_default: attr.is_some_and(|value| value.atthasdef),
not_null: attr.is_some_and(|value| value.attnotnull.is_some_and(|notnull| notnull)),
prompts,
})
}
let mut prompts_html: HashMap<String, String> = HashMap::new();
for field in fields.iter() {
// TODO: i18n
let prompt = field
.prompts
.get(&Language::Eng)
.cloned()
.unwrap_or_default();
let prompt_md = markdown::to_html(&prompt);
// TODO: a11y (input labels)
prompts_html.insert(field.field.name.clone(), prompt_md);
}
#[derive(Debug, Template)]
#[template(path = "forms/form_index.html")]
struct ResponseTemplate {
fields: Vec<FormFieldInfo>,
language: Language,
portal: Portal,
prompts_html: HashMap<String, String>,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
fields,
language: Language::Eng,
portal,
prompts_html,
settings,
}
.render()?,
)
.into_response())
}

View file

@ -1,10 +0,0 @@
use axum::{Router, routing::get};
use axum_extra::routing::RouterExt as _;
use crate::app::App;
mod form_handler;
pub(super) fn new_router() -> Router<App> {
Router::new().route_with_tsr("/{portal_id}/", get(form_handler::get))
}

View file

@ -21,7 +21,6 @@ use tower_http::{
use crate::{app::App, auth, settings::Settings}; use crate::{app::App, auth, settings::Settings};
mod forms;
mod relations_single; mod relations_single;
mod workspaces_multi; mod workspaces_multi;
mod workspaces_single; mod workspaces_single;
@ -42,7 +41,6 @@ pub(crate) fn new_router(app: App) -> Router<()> {
) )
.nest("/workspaces", workspaces_multi::new_router()) .nest("/workspaces", workspaces_multi::new_router())
.nest("/w", workspaces_single::new_router()) .nest("/w", workspaces_single::new_router())
.nest("/f", forms::new_router())
.nest("/auth", auth::new_router()) .nest("/auth", auth::new_router())
.route("/__dev-healthz", get(|| async move { "ok" })) .route("/__dev-healthz", get(|| async move { "ok" }))
.layer(SetResponseHeaderLayer::if_not_present( .layer(SetResponseHeaderLayer::if_not_present(

View file

@ -1,176 +0,0 @@
use std::collections::HashMap;
use askama::Template;
use axum::{
debug_handler,
extract::{Path, State},
response::{Html, IntoResponse},
};
use phono_backends::pg_attribute::PgAttribute;
use phono_models::{
accessors::{Accessor as _, Actor, portal::PortalAccessor},
field::Field,
field_form_prompt::FieldFormPrompt,
form_transition::FormTransition,
language::Language,
portal::Portal,
workspace::Workspace,
};
use serde::{Deserialize, Serialize};
use sqlx::postgres::types::Oid;
use strum::IntoEnumIterator as _;
use uuid::Uuid;
use crate::{
app::AppDbConn,
errors::AppError,
field_info::FormFieldInfo,
navigator::{Navigator, NavigatorPage as _},
settings::Settings,
user::CurrentUser,
workspace_nav::{NavLocation, RelLocation, WorkspaceNav},
workspace_pooler::{RoleAssignment, WorkspacePooler},
workspaces::{RelationPortalSet, fetch_all_accessible_portals},
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
portal_id: Uuid,
rel_oid: u32,
workspace_id: Uuid,
}
/// HTTP GET handler for the top-level portal form builder page.
#[debug_handler(state = crate::app::App)]
pub(super) async fn get(
State(settings): State<Settings>,
CurrentUser(user): CurrentUser,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
navigator: Navigator,
State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> {
let mut workspace_client = pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?;
let portal = PortalAccessor::new()
.id(portal_id)
.as_actor(Actor::User(user.id))
.verify_workspace_id(workspace_id)
.verify_rel_oid(Oid(rel_oid))
.verify_rel_ownership()
.using_app_db(&mut app_db)
.using_workspace_client(&mut workspace_client)
.fetch_one()
.await?;
let workspace = Workspace::with_id(portal.workspace_id)
.fetch_one(&mut app_db)
.await?;
let attrs: HashMap<String, PgAttribute> = PgAttribute::all_for_rel(portal.class_oid)
.fetch_all(&mut workspace_client)
.await?
.into_iter()
.map(|attr| (attr.attname.clone(), attr))
.collect();
let fields: Vec<FormFieldInfo> = {
let fields: Vec<Field> = Field::belonging_to_portal(portal.id)
.fetch_all(&mut app_db)
.await?;
let mut field_info: Vec<FormFieldInfo> = Vec::with_capacity(fields.len());
for field in fields {
let attr = attrs.get(&field.name);
let prompts: HashMap<Language, String> = FieldFormPrompt::belonging_to_field(field.id)
.fetch_all(&mut app_db)
.await?
.into_iter()
.map(|value| (value.language, value.content))
.collect();
field_info.push(FormFieldInfo {
field,
column_present: attr.is_some(),
has_default: attr.map(|value| value.atthasdef).unwrap_or(false),
not_null: attr.and_then(|value| value.attnotnull).unwrap_or_default(),
prompts,
});
}
field_info
};
// FIXME: exclude portals user does not have access to, as well as
// unnecessary fields
let portal_sets =
fetch_all_accessible_portals(workspace_id, &mut app_db, &mut workspace_client).await?;
#[derive(Template)]
#[template(path = "relations_single/form_index.html")]
struct ResponseTemplate {
fields: Vec<FormFieldInfo>,
identifier_hints: Vec<String>,
languages: Vec<LanguageInfo>,
navigator: Navigator,
portal: Portal,
portals: Vec<PortalDisplay>,
settings: Settings,
transitions: Vec<FormTransition>,
workspace_nav: WorkspaceNav,
}
#[derive(Debug, Serialize)]
struct LanguageInfo {
code: String,
locale_str: String,
}
#[derive(Debug, Serialize)]
struct PortalDisplay {
id: Uuid,
display_name: String,
}
Ok(Html(
ResponseTemplate {
fields,
identifier_hints: attrs.keys().cloned().collect(),
languages: Language::iter()
.map(|value| LanguageInfo {
code: value.to_string(),
locale_str: value.as_locale_str().to_owned(),
})
.collect(),
portal,
portals: portal_sets
.iter()
.flat_map(|RelationPortalSet { rel, portals }| {
portals.iter().map(|portal| PortalDisplay {
id: portal.id,
display_name: format!(
"{rel_name}: {portal_name}",
rel_name = rel.relname,
portal_name = portal.name
),
})
})
.collect(),
transitions: FormTransition::with_source(portal_id)
.fetch_all(&mut app_db)
.await?,
workspace_nav: WorkspaceNav::builder()
.navigator(navigator.clone())
.workspace(workspace)
.populate_rels(&mut app_db, &mut workspace_client)
.await?
.current(NavLocation::Rel(
Oid(rel_oid),
Some(RelLocation::Portal(portal_id)),
))
.build()?,
navigator,
settings,
}
.render()?,
))
}

View file

@ -8,7 +8,6 @@ use crate::app::App;
mod add_field_handler; mod add_field_handler;
mod add_portal_handler; mod add_portal_handler;
mod form_handler;
mod get_data_handler; mod get_data_handler;
mod insert_handler; mod insert_handler;
mod portal_handler; mod portal_handler;
@ -18,9 +17,7 @@ mod set_filter_handler;
mod settings_handler; mod settings_handler;
mod update_field_handler; mod update_field_handler;
mod update_field_ordinality_handler; mod update_field_ordinality_handler;
mod update_form_transitions_handler;
mod update_portal_name_handler; mod update_portal_name_handler;
mod update_prompts_handler;
mod update_rel_name_handler; mod update_rel_name_handler;
mod update_values_handler; mod update_values_handler;
@ -58,13 +55,4 @@ pub(super) fn new_router() -> Router<App> {
post(update_values_handler::post), post(update_values_handler::post),
) )
.route("/p/{portal_id}/set-filter", post(set_filter_handler::post)) .route("/p/{portal_id}/set-filter", post(set_filter_handler::post))
.route_with_tsr("/p/{portal_id}/form/", get(form_handler::get))
.route(
"/p/{portal_id}/form/update-prompts",
post(update_prompts_handler::post),
)
.route(
"/p/{portal_id}/form/update-form-transitions",
post(update_form_transitions_handler::post),
)
} }

View file

@ -94,7 +94,7 @@ pub(super) async fn get(
.await?; .await?;
#[derive(Template)] #[derive(Template)]
#[template(path = "portal_table.html")] #[template(path = "relations_single/portal_table.html")]
struct ResponseTemplate { struct ResponseTemplate {
columns: Vec<ColumnInfo>, columns: Vec<ColumnInfo>,
attr_names: Vec<String>, attr_names: Vec<String>,

View file

@ -1,80 +0,0 @@
use std::iter::zip;
use axum::{debug_handler, extract::Path, response::Response};
// [`axum_extra`]'s form extractor is required to support repeated keys:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use phono_models::form_transition::{self, FormTransition};
use serde::Deserialize;
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use crate::{
app::AppDbConn,
errors::{AppError, bad_request},
navigator::{Navigator, NavigatorPage as _},
user::CurrentUser,
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
portal_id: Uuid,
rel_oid: u32,
workspace_id: Uuid,
}
#[derive(Clone, Debug, Deserialize)]
pub(super) struct FormBody {
dest: Vec<Uuid>,
condition: Vec<String>,
}
/// HTTP POST handler for setting form transitions for a [`Portal`]. The form
/// body is expected to be an HTTP form encoded with a list of inputs named
/// `"dest"` and `"condition"`, where `"dest"` encodes a portal ID and
/// `"condition"` is JSON deserializing to [`PgExpressionAny`].
///
/// Upon success, the client is redirected back to the portal's form editor
/// page.
#[debug_handler(state = crate::app::App)]
pub(super) async fn post(
AppDbConn(mut app_db): AppDbConn,
CurrentUser(_user): CurrentUser,
navigator: Navigator,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
Form(form): Form<FormBody>,
) -> Result<Response, AppError> {
// FIXME: Check workspace authorization.
// FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed.
// FIXME CSRF
let replacements = zip(form.dest, form.condition)
.map(|(dest_id, condition)| {
Ok(form_transition::Replacement {
dest_id,
condition: serde_json::from_str(&condition)?,
})
})
.collect::<Result<Vec<_>, serde_json::Error>>()
.map_err(|err| bad_request!("unable to deserialize condition: {err}"))?;
FormTransition::replace_for_portal(portal_id)
.replacements(replacements)
.build()?
.execute(&mut app_db)
.await?;
Ok(navigator
.portal_page()
.workspace_id(workspace_id)
.rel_oid(Oid(rel_oid))
.portal_id(portal_id)
.suffix("form/")
.build()?
.redirect_to())
}

View file

@ -1,115 +0,0 @@
use std::{collections::HashMap, str::FromStr};
use axum::{
debug_handler,
extract::{Path, State},
response::Response,
};
// [`axum_extra`]'s form extractor is required to support repeated keys:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use phono_models::{
accessors::{Accessor, Actor, portal::PortalAccessor},
field::Field,
field_form_prompt::FieldFormPrompt,
language::Language,
};
use serde::Deserialize;
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use crate::{
app::AppDbConn,
errors::{AppError, bad_request},
navigator::{Navigator, NavigatorPage as _},
user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
portal_id: Uuid,
rel_oid: u32,
workspace_id: Uuid,
}
/// HTTP POST handler for setting form prompt content on all fields within a
/// single [`Portal`]. The form body is expected to be an HTTP form encoded
/// mapping of `<FIELD_ID>.<LANGUAGE_CODE>` to content.
///
/// Upon success, the client is redirected back to the portal's form editor
/// page.
#[debug_handler(state = crate::app::App)]
pub(super) async fn post(
State(mut workspace_pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
// FIXME CSRF
let mut workspace_client = workspace_pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?;
let portal = PortalAccessor::new()
.id(portal_id)
.as_actor(Actor::User(user.id))
.verify_workspace_id(workspace_id)
.verify_rel_oid(Oid(rel_oid))
.verify_rel_ownership()
.using_app_db(&mut app_db)
.using_workspace_client(&mut workspace_client)
.fetch_one()
.await?;
// TODO: This can be sped up somewhat with streams, because queries using
// `app_db` can run at the same time as others using `app_db` or
// `workspace_client`.
for (name, content) in form {
let mut name_split = name.split('.');
let field_id = name_split
.next()
.and_then(|value| Uuid::parse_str(value).ok())
.ok_or(bad_request!("expected input name to start with <FIELD_ID>"))?;
// For authorization.
let _field = Field::belonging_to_portal(portal.id)
.with_id(field_id)
.fetch_one(&mut app_db)
.await?;
let language = name_split
.next()
.and_then(|value| Language::from_str(value).ok())
.ok_or(bad_request!(
"expected input name to be <FIELD_ID>.<LANGUAGE_CODE>"
))?;
if name_split.next().is_some() {
return Err(bad_request!("input name longer than expected"));
}
FieldFormPrompt::upsert()
.field_id(field_id)
.language(language)
.content(content)
.build()?
.execute(&mut app_db)
.await?;
}
// FIXME redirect to the correct page
Ok(navigator
.portal_page()
.workspace_id(workspace_id)
.rel_oid(Oid(rel_oid))
.portal_id(portal_id)
.suffix("form/")
.build()?
.redirect_to())
}

View file

@ -12,7 +12,7 @@ use crate::{
}; };
#[derive(Builder, Clone, Debug, Template)] #[derive(Builder, Clone, Debug, Template)]
#[template(path = "workspace_nav.html")] #[template(path = "includes/workspace_nav.html")]
pub(crate) struct WorkspaceNav { pub(crate) struct WorkspaceNav {
workspace: Workspace, workspace: Workspace,
relations: Vec<RelationItem>, relations: Vec<RelationItem>,

View file

@ -1,43 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ portal.name }}{% endblock %}
{% block head_extras %}
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/form.css">
{% endblock %}
{% block main %}
<main class="phono-form__container">
<form action="submit" method="post">
<section>
{% for field in fields %}
<div class="phono-form__field">
{% if field.column_present %}
{{ prompts_html.get(field.field.name).cloned().unwrap_or_default() | safe }}
{% match field.field.presentation.0 %}
{% when Presentation::Text { input_mode } %}
{% match input_mode %}
{% when TextInputMode::SingleLine %}
<input
type="text"
class="form-section__input form-section__input--text"
name="{{ field.field.name }}"
>
{% when TextInputMode::MultiLine %}
<textarea name="{{ field.field.name }}"></textarea>
{% else %}
{% endmatch %}
{% else %}
{% endmatch %}
{% endif %}
</div>
{% endfor %}
</section>
<section>
<button class="button--primary" type="submit">
Continue
</button>
</section>
</form>
</main>
{% endblock %}

View file

@ -0,0 +1,12 @@
<basic-dropdown alignment="right">
<span slot="button-contents" aria-label="Account menu" title="Account menu">
<i aria-hidden="true" class="ti ti-user"></i>
</span>
<menu class="basic-dropdown__menu" slot="popover">
<li>
<a href="{{ settings.root_path }}/auth/logout" role="button">
Log out
</a>
</li>
</menu>
</basic-dropdown>

View file

@ -1,15 +1,19 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8">
<meta name="robots" content="noindex,nofollow">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg" href="{{ settings.root_path }}/phono.svg">
<title>{% block title %}Phonograph{% endblock %}</title> <title>{% block title %}Phonograph{% endblock %}</title>
{% include "meta_tags.html" %}
<link rel="stylesheet" href="{{ settings.root_path }}/modern-normalize.min.css"> <link rel="stylesheet" href="{{ settings.root_path }}/modern-normalize.min.css">
<link rel="stylesheet" href="{{ settings.root_path }}/main.css"> <link rel="stylesheet" href="{{ settings.root_path }}/main.css">
<link rel="stylesheet" href="{{ settings.root_path }}/tabler-icons/webfont/tabler-icons.min.css"> <link rel="stylesheet" href="{{ settings.root_path }}/tabler-icons/webfont/tabler-icons.min.css">
<script type="module" src="{{ settings.root_path }}/js_dist/basic-dropdown.webc.mjs"></script> {%- block head %}{% endblock -%}
{%- block head_extras %}{% endblock -%}
</head> </head>
<body> <body>
{% block main %}{% endblock %} {%- block body %}{% endblock -%}
<script type="module" src="{{ settings.root_path }}/js_dist/basic-dropdown.webc.mjs"></script>
{%- block scripts %}{% endblock -%}
</body> </body>
</html> </html>

View file

@ -0,0 +1,29 @@
{% extends "layouts/base.html" %}
{% block body %}
<div class="layout-with-sidebar">
<header class="banner" role="note">
{%- block banner %}
<i class="ti ti-info-square-rounded"></i>
This is a technical demo.
<a href="https://www.phono.dev/about#demo">
Learn more <i class="ti ti-chevron-right"></i>
</a>
{% endblock -%}
</header>
<header class="toolbar" role="banner">
<div class="toolbar__page-actions">
{% block toolbar_page_actions %}{% endblock %}
</div>
<div class="toolbar__user">
{% include "includes/toolbar_user.html" %}
</div>
</header>
<div class="sidebar">
{%- block sidebar %}{% endblock -%}
</div>
<main>
{%- block main %}{% endblock -%}
</main>
</div>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends "layouts/base.html" %}
{% block body %}
<div class="layout-with-toolbar">
<header class="banner" role="note">
{%- block banner %}
<i class="ti ti-info-square-rounded"></i>
This is a technical demo.
<a href="https://www.phono.dev/about#demo">
Learn more <i class="ti ti-chevron-right"></i>
</a>
{% endblock -%}
</header>
<header class="toolbar" role="banner">
<div class="toolbar__page-actions">
{% block toolbar_page_actions %}{% endblock %}
</div>
<div class="toolbar__user">
{% include "includes/toolbar_user.html" %}
</div>
</header>
<main>
{%- block main %}{% endblock -%}
</main>
</div>
{% endblock %}

View file

@ -1,4 +0,0 @@
<meta charset="UTF-8">
<meta name="robots" content="noindex,nofollow">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg" href="{{ settings.root_path }}/phono.svg">

View file

@ -1,37 +0,0 @@
{% extends "base.html" %}
{% block head_extras %}
<link rel="stylesheet" href="{{ settings.root_path }}/portal-table.css">
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
{% endblock %}
{% block main %}
<div class="page-grid">
<div class="page-grid__toolbar">
<div class="page-grid__toolbar-utilities">
<a class="button button--secondary" href="settings" role="button">
Portal Settings
</a>
<filter-menu
identifier-hints="{{ attr_names | json }}"
initial-value="{{ filter | json }}"
></filter-menu>
</div>
{% include "toolbar_user.html" %}
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ navbar | safe }}
</div>
</div>
<main class="page-grid__main">
<table-viewer
columns="{{ columns | json }}"
{%- if subfilter_str != "" %}
subfilter="{{ subfilter_str }}"
{% endif -%}
></table-viewer>
</main>
</div>
{% endblock %}

View file

@ -1,23 +0,0 @@
{% extends "base.html" %}
{% block main %}
<table>
<thead>
<tr>
<th>Email</th>
<th>User ID</th>
<th>Roles</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.user.email }}</td>
<td>{{ role_prefix }}{{ user.user.id.simple() }}</td>
<td></td>
<td>...</td>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -1,13 +0,0 @@
{% extends "base.html" %}
{% block main %}
<form method="post" action="">
<div>
<label for="email">
Email Address
</label>
<input type="text" name="email" inputmode="email">
<button type="submit">Invite</button>
</div>
</form>
{% endblock %}

View file

@ -1,72 +0,0 @@
{% extends "base.html" %}
{% block main %}
<div class="page-grid">
<div class="page-grid__toolbar"></div>
<div class="page-grid__sidebar">
{{ navbar | safe }}
</div>
<main class="page-grid__main">
<section class="section">
<h1>Sharing</h1>
</section>
<section class="section">
<h2>Table Owners</h2>
<p class="notice notice--info">
Owners are able to edit table structure, including configuring columns,
adding, updating, and deleting record data, and dropping the table
entirely from the database.
</p>
<p class="notice notice--info">
Each table in Postgres has exactly one owner role, so it's typically
best practice to create a dedicated role for this purpose and then grant
membership of that role to one or more users.
</p>
{{ owners | safe }}
</section>
<section class="section">
<h2>Invitations</h2>
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
Invite Collaborators
</a>
<table class="users-table">
<thead>
<tr>
<th class="users-table__th">Email</th>
{# rolname is intentionally hidden in a submenu (todo), as it is
likely to confuse new users #}
<th class="users-table__th">Privileges</th>
<th class="users-table__th"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
{# place invitations at beginning of list as they're liable to cause
unpleasant surprises if forgotten #}
{% for (email, invites) in invites_by_email %}
<tr>
<td class="users-table__td">{{ email }}</td>
<td class="users-table__td">
<code>{% for invite in invites %}{{ invite.privilege }}{% endfor %}</code>
</td>
<td class="users-table__td"></td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="section">
<h2>Permissions</h2>
<ul>
{% for acl_tree in acl_trees %}
<li>
<div>
{% for privilege in acl_tree.acl_item.privileges %}{{ privilege.privilege.to_abbrev() }}{% endfor %}
</div>
{{ acl_tree.grantees | safe }}
</li>
{% endfor %}
</ul>
</section>
</main>
</div>
{% endblock %}

View file

@ -1,57 +0,0 @@
{% extends "base.html" %}
{% block main %}
<div class="page-grid">
<div class="page-grid__toolbar">
<div class="page-grid__toolbar-utilities">
<a href="{{ navigator.form_page(*portal.id).build()?.get_path() }}">
<button class="button--secondary" style="margin-left: 0.5rem;" type="button">View Form</button>
</a>
</div>
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main">
<form method="post" action="update-prompts">
<section>
<h1>Prompts</h1>
<div>
{% for field_info in fields %}
<div class="form-editor__field">
<div class="form-editor__field-label">
{{ field_info.field.table_label.clone().unwrap_or(field_info.field.name.clone()) }}
</div>
<div class="form-editor__field-prompt">
<i18n-textarea
field-id="{{ field_info.field.id }}"
languages="{{ languages | json }}"
value="{{ field_info.prompts | json }}"
>
</i18n-textarea>
</div>
</div>
{% endfor %}
</div>
<button class="button--primary" type="submit">Save Prompts</button>
</section>
</form>
<form method="post" action="update-form-transitions">
<section>
<h1>Destinations</h1>
<form-transitions-editor
identifier-hints="{{ identifier_hints | json }}"
portals="{{ portals | json }}"
value="{{ transitions | json }}"
>
</form-transitions-editor>
<button class="button--primary" type="submit">Save Destinations</button>
</section>
</form>
</main>
</div>
<script type="module" src="{{ settings.root_path }}/js_dist/i18n-textarea.webc.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/form-transitions-editor.webc.mjs"></script>
{% endblock %}

View file

@ -1,49 +1,44 @@
{% extends "base.html" %} {% extends "layouts/with_sidebar.html" %}
{% block toolbar_page_actions %}
<a
class="button button--secondary"
href="{{ navigator.portal_page()
.workspace_id(*portal.workspace_id)
.rel_oid(*portal.class_oid)
.portal_id(*portal.id)
.build()?
.get_path() }}"
role="button"
>
Back
</a>
{% endblock %}
{% block sidebar %}
{{ workspace_nav | safe }}
{% endblock %}
{% block main %} {% block main %}
<div class="page-grid"> <div class="padded padded--lg">
<div class="page-grid__toolbar"> <form method="post" action="update-name">
<div class="page-grid__toolbar-utilities"> <section>
<a <h1>Portal Name</h1>
class="button button--secondary" <input
href="{{ navigator.portal_page() type="text"
.workspace_id(*portal.workspace_id) autocomplete="off"
.rel_oid(*portal.class_oid) class="form__input"
.portal_id(*portal.id) data-1p-ignore
.build()? data-bwignore="true"
.get_path() }}" data-lpignore="true"
role="button" data-protonpass-ignore="true"
name="name"
value="{{ portal.name }}"
> >
Back <div class="form__buttons">
</a> <button class="button button--primary" type="submit">Save</button>
</div> </div>
{% include "toolbar_user.html" %} </section>
</div> </form>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main padded padded--lg">
<form method="post" action="update-name">
<section>
<h1>Portal Name</h1>
<input
type="text"
autocomplete="off"
class="form__input"
data-1p-ignore
data-bwignore="true"
data-lpignore="true"
data-protonpass-ignore="true"
name="name"
value="{{ portal.name }}"
>
<div class="form__buttons">
<button class="button button--primary" type="submit">Save</button>
</div>
</section>
</form>
</main>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,33 @@
{% extends "layouts/with_sidebar.html" %}
{% block head %}
<link rel="stylesheet" href="{{ settings.root_path }}/portal-table.css">
{% endblock %}
{% block toolbar_page_actions %}
<a class="button button--secondary" href="settings" role="button">
Portal Settings
</a>
<filter-menu
identifier-hints="{{ attr_names | json }}"
initial-value="{{ filter | json }}"
></filter-menu>
{% endblock %}
{% block sidebar %}
{{ navbar | safe }}
{% endblock %}
{% block main %}
<table-viewer
columns="{{ columns | json }}"
{%- if subfilter_str != "" %}
subfilter="{{ subfilter_str }}"
{% endif -%}
></table-viewer>
{% endblock %}
{% block scripts %}
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
{% endblock %}

View file

@ -1,35 +1,29 @@
{% extends "base.html" %} {% extends "layouts/with_sidebar.html" %}
{% block sidebar %}
{{ workspace_nav | safe }}
{% endblock %}
{% block main %} {% block main %}
<div class="page-grid"> <div class="padded padded--lg">
<div class="page-grid__toolbar"> <form method="post" action="update-name">
{% include "toolbar_user.html" %} <section>
</div> <h1>Table Name</h1>
<div class="page-grid__sidebar"> <input
<div style="padding: 1rem;"> type="text"
{{ workspace_nav | safe }} autocomplete="off"
</div> class="form__input"
</div> data-1p-ignore
<main class="page-grid__main padded padded--lg"> data-bwignore="true"
<form method="post" action="update-name"> data-lpignore="true"
<section> data-protonpass-ignore="true"
<h1>Table Name</h1> name="name"
<input value="{{ rel.relname }}"
type="text" >
autocomplete="off" <div class="form__buttons">
class="form__input" <button class="button button--primary" type="submit">Save</button>
data-1p-ignore </div>
data-bwignore="true" </section>
data-lpignore="true" </form>
data-protonpass-ignore="true"
name="name"
value="{{ rel.relname }}"
>
<div class="form__buttons">
<button class="button button--primary" type="submit">Save</button>
</div>
</section>
</form>
</main>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,14 +0,0 @@
<div class="page-grid__toolbar-user">
<basic-dropdown alignment="right">
<span slot="button-contents" aria-label="Account menu" title="Account menu">
<i aria-hidden="true" class="ti ti-user"></i>
</span>
<menu class="basic-dropdown__menu" slot="popover">
<li>
<a href="{{ settings.root_path }}/auth/logout" role="button">
Log out
</a>
</li>
</menu>
</basic-dropdown>
</div>

View file

@ -1,52 +1,42 @@
{% extends "base.html" %} {% extends "layouts/with_toolbar.html" %}
{% block head_extras %}
{% endblock %}
{% block main %} {% block main %}
<div class="page-grid"> <nav class="workspace-nav" style="position: relative; margin: 0 auto; max-width: 540px;">
<div class="page-grid__toolbar"> <section class="workspace-nav__section">
{% include "toolbar_user.html" %} <div class="workspace-nav__heading">
</div> <h1>My Workspaces</h1>
<main class="page-grid__main"> <form method="post" action="add">
<nav class="workspace-nav" style="position: relative; margin: 0 auto; max-width: 540px;"> <button class="button--secondary button--small" type="submit">
<section class="workspace-nav__section"> <i class="ti ti-plus"><div class="sr-only">Add workspace</div></i>
<div class="workspace-nav__heading"> </button>
<h1>My Workspaces</h1> </form>
<form method="post" action="add"> </div>
<button class="button--secondary button--small" type="submit"> <menu class="workspace-nav__menu">
<i class="ti ti-plus"><div class="sr-only">Add workspace</div></i> {% for workspace_perm in workspace_perms %}
</button> <li class="workspace-nav__menu-item">
</form> <div class="workspace-nav__menu-leaf">
<a href="
{{- navigator
.workspace_page()
.workspace_id(*workspace_perm.workspace_id)
.build()?
.get_path() -}}
" class="workspace-nav__menu-link">
{% if workspace_perm.workspace_display_name.is_empty() %}
[Untitled Workspace]
{% else %}
{{ workspace_perm.workspace_display_name }}
{% endif %}
</a>
</div> </div>
<menu class="workspace-nav__menu"> </li>
{% for workspace_perm in workspace_perms %} {% endfor %}
<li class="workspace-nav__menu-item"> </menu>
<div class="workspace-nav__menu-leaf"> </section>
<a href=" <section class="workspace-nav__section">
{{- navigator <div class="workspace-nav__heading">
.workspace_page() <h1>Shared With Me</h1>
.workspace_id(*workspace_perm.workspace_id) </div>
.build()? </section>
.get_path() -}} </nav>
" class="workspace-nav__menu-link">
{% if workspace_perm.workspace_display_name.is_empty() %}
[Untitled Workspace]
{% else %}
{{ workspace_perm.workspace_display_name }}
{% endif %}
</a>
</div>
</li>
{% endfor %}
</menu>
</section>
<section class="workspace-nav__section">
<div class="workspace-nav__heading">
<h1>Shared With Me</h1>
</div>
</section>
</nav>
</main>
</div>
{% endblock %} {% endblock %}

View file

@ -1,16 +1,5 @@
{% extends "base.html" %} {% extends "layouts/with_sidebar.html" %}
{% block main %} {% block sidebar %}
<div class="page-grid"> {{ workspace_nav | safe }}
<div class="page-grid__toolbar">
{% include "toolbar_user.html" %}
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main">
</main>
</div>
{% endblock %} {% endblock %}

View file

@ -1,63 +1,58 @@
{% extends "base.html" %} {% extends "layouts/with_sidebar.html" %}
{% block head_extras %} {% block toolbar_page_actions %}
<script type="module" src="{{ settings.root_path }}/js_dist/copy-source.webc.mjs"></script> <basic-dropdown button-class="button--secondary" button-label="New Credential">
<span slot="button-contents">New Credential</span>
<div class="padded" slot="popover">
<form action="add-service-credential" method="post">
<button class="button--secondary" type="submit">Confirm</button>
</form>
</div>
</basic-dropdown>
{% endblock %}
{% block sidebar %}
{{ workspace_nav | safe }}
{% endblock %} {% endblock %}
{% block main %} {% block main %}
<div class="page-grid"> <div class="page-grid__main padded padded--lg">
<div class="page-grid__toolbar"> <table class="table">
<div class="page-grid__toolbar-utilities"> <thead>
<basic-dropdown button-class="button--secondary" button-label="New Credential"> <tr>
<span slot="button-contents">New Credential</span> <th scope="col">Connection String</th>
<div class="padded" slot="popover"> <th scope="col">Permissions</th>
<form action="add-service-credential" method="post"> <th scope="col">Actions</th>
<button class="button--secondary" type="submit">Confirm</button> </tr>
</form> </thead>
</div> <tbody>
</basic-dropdown> {% if service_cred_info.is_empty() %}
</div> <tr class="table__message">
{% include "toolbar_user.html" %} <td colspan="999">No data</td>
</div> </tr>
<div class="page-grid__sidebar"> {% endif %}
<div style="padding: 1rem;"> {% for cred in service_cred_info %}
{{ workspace_nav | safe }} <tr>
</div> <td>
</div> <copy-source copy-data="{{ cred.conn_string.expose_secret() }}">
<main class="page-grid__main padded padded--lg"> <span aria-label="Copy postgresql URL" title="Copy URL">
<table class="table"> <code class="phono-code">{{ cred.conn_string_redacted }}</code>
<thead> <i class="ti ti-clipboard"></i>
<tr> </span>
<th scope="col">Connection String</th> <code class="phono-code" slot="fallback">{{ cred.conn_string.expose_secret() }}</code>
<th scope="col">Permissions</th> </copy-source>
<th scope="col">Actions</th> </td>
</tr> <td>
</thead> {{ cred.permissions_editor | safe }}
<tbody> </td>
{% if service_cred_info.is_empty() %} <td></td>
<tr class="table__message"> </tr>
<td colspan="999">No data</td> {% endfor %}
</tr> </tbody>
{% endif %} <table>
{% for cred in service_cred_info %}
<tr>
<td>
<copy-source copy-data="{{ cred.conn_string.expose_secret() }}">
<span aria-label="Copy postgresql URL" title="Copy URL">
<code class="phono-code">{{ cred.conn_string_redacted }}</code>
<i class="ti ti-clipboard"></i>
</span>
<code class="phono-code" slot="fallback">{{ cred.conn_string.expose_secret() }}</code>
</copy-source>
</td>
<td>
{{ cred.permissions_editor | safe }}
</td>
<td></td>
</tr>
{% endfor %}
</tbody>
<table>
</main>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script type="module" src="{{ settings.root_path }}/js_dist/copy-source.webc.mjs"></script>
{% endblock %}

View file

@ -1,103 +1,93 @@
{% extends "base.html" %} {% extends "layouts/with_sidebar.html" %}
{% block main %} {% block main %}
<div class="page-grid"> <div class="padded padded--lg">
<div class="page-grid__toolbar"> <form method="post" action="update-name">
{% include "toolbar_user.html" %}
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main padded padded--lg">
<form method="post" action="update-name">
<section>
<h1>Workspace Name</h1>
<input
type="text"
autocomplete="off"
class="form__input"
data-1p-ignore
data-bwignore="true"
data-lpignore="true"
data-protonpass-ignore="true"
name="name"
value="{{ workspace.display_name }}"
>
<div class="form__buttons">
<button class="button button--primary" type="submit">Save</button>
</div>
</section>
</form>
<section> <section>
<h1>Sharing</h1> <h1>Workspace Name</h1>
<div class="form__buttons"> <input
<button class="button button--primary" popovertarget="invite-dialog" type="button"> type="text"
Invite autocomplete="off"
</button> class="form__input"
</div> data-1p-ignore
<dialog data-bwignore="true"
class="dialog padded padded--lg" data-lpignore="true"
id="invite-dialog" data-protonpass-ignore="true"
popover="auto" name="name"
value="{{ workspace.display_name }}"
> >
<form action="grant-workspace-privilege" method="post">
<div class="form-grid">
<div class="form-grid-row">
<label for="invite-dialog-email-input">Email</label>
<input
type="text"
id="invite-dialog-email-input"
autocomplete="off"
data-1p-ignore
data-bwignore="true"
data-lpignore="true"
data-protonpass-ignore="true"
inputmode="email"
name="email"
>
</div>
<input type="hidden" name="csrf_token" value="FIXME">
<button class="button button--primary" type="submit">
Invite
</button>
</div>
</form>
</dialog>
<table class="table" style="margin-top: var(--default-padding);">
<thead>
<tr>
<th scope="col">Email</th>
<th scope="col">Permissions</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for permissions_editor in collaborators %}
<tr>
{% let collaborator = User::try_from(permissions_editor.target.clone())? %}
<td class="text--data">{{ collaborator.email }}</td>
<td>
{{ permissions_editor | safe }}
</td>
<td>
<form action="revoke-workspace-privileges" method="post">
<input type="hidden" name="user_id" value="{{ collaborator.id.simple() }}">
<input type="hidden" name="csrf_token" value="FIXME">
<button type="submit" class="button button--secondary button--small">
Revoke
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="form__buttons"> <div class="form__buttons">
<button class="button button--primary" type="submit">Save</button> <button class="button button--primary" type="submit">Save</button>
</div> </div>
</section> </section>
</main> </form>
<section>
<h1>Sharing</h1>
<div class="form__buttons">
<button class="button button--primary" popovertarget="invite-dialog" type="button">
Invite
</button>
</div>
<dialog
class="dialog padded padded--lg"
id="invite-dialog"
popover="auto"
>
<form action="grant-workspace-privilege" method="post">
<div class="form-grid">
<div class="form-grid-row">
<label for="invite-dialog-email-input">Email</label>
<input
type="text"
id="invite-dialog-email-input"
autocomplete="off"
data-1p-ignore
data-bwignore="true"
data-lpignore="true"
data-protonpass-ignore="true"
inputmode="email"
name="email"
>
</div>
<input type="hidden" name="csrf_token" value="FIXME">
<button class="button button--primary" type="submit">
Invite
</button>
</div>
</form>
</dialog>
<table class="table" style="margin-top: var(--default-padding);">
<thead>
<tr>
<th scope="col">Email</th>
<th scope="col">Permissions</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for permissions_editor in collaborators %}
<tr>
{% let collaborator = User::try_from(permissions_editor.target.clone())? %}
<td class="text--data">{{ collaborator.email }}</td>
<td>
{{ permissions_editor | safe }}
</td>
<td>
<form action="revoke-workspace-privileges" method="post">
<input type="hidden" name="user_id" value="{{ collaborator.id.simple() }}">
<input type="hidden" name="csrf_token" value="FIXME">
<button type="submit" class="button button--secondary button--small">
Revoke
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="form__buttons">
<button class="button button--primary" type="submit">Save</button>
</div>
</section>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -34,7 +34,7 @@
--default-padding--sm: 8px; --default-padding--sm: 8px;
--default-padding--lg: 32px; --default-padding--lg: 32px;
--a-color: #069; --a-color: oklch(from var(--accent-color) 0.5 0.15 calc(h + 135));
--button-background--primary: var(--accent-color); --button-background--primary: var(--accent-color);
--button-background--secondary: #fff; --button-background--secondary: #fff;
@ -51,8 +51,6 @@
--button-padding: var(--button-padding--default); --button-padding: var(--button-padding--default);
--button-shadow: 0 2px 2px #3331; --button-shadow: 0 2px 2px #3331;
--notice-color--info: #39d;
--popover-border-color: var(--default-border-color); --popover-border-color: var(--default-border-color);
--popover-shadow: 0 8px 8px #3333; --popover-shadow: 0 8px 8px #3333;
} }
@ -251,19 +249,6 @@ a {
padding: 1rem 2rem; padding: 1rem 2rem;
} }
.notice {
border-radius: var(--default-border-radius--rounded);
margin: 1rem 0rem;
padding: 1rem;
max-width: 40rem;
&.notice--info {
border: solid 1px globals.$notice-color-info;
background: oklch(from var(--notice-color--info) calc(l * 0.9) c h);
color: oklch(from var(--notice-color--info) calc(l * 0.2) c h);
}
}
.permissions-list { .permissions-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -343,27 +328,63 @@ a {
/* ======== Layout ======== */ /* ======== Layout ======== */
.page-grid { .layout-with-sidebar, .layout-with-toolbar {
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
display: grid; display: grid;
grid-template: grid-template:
'sidebar toolbar' 4rem 'banner banner' max-content
'sidebar toolbar' 64px
'sidebar main' 1fr / max-content 1fr; 'sidebar main' 1fr / max-content 1fr;
.page-grid__toolbar { .banner {
align-items: center; grid-area: banner;
border-bottom: solid 1px var(--default-border-color);
display: grid;
grid-area: toolbar;
grid-template: 'utilities user' 1fr / 1fr max-content;
.toolbar-item {
flex: 0;
}
} }
.page-grid__toolbar-utilities { .toolbar {
grid-area: toolbar;
}
.sidebar {
grid-area: sidebar;
}
main {
grid-area: main;
overflow: auto;
}
}
.banner {
background: oklch(from var(--accent-color) 0.5 0.08 calc(h + 135));
color: #fff;
padding: 8px;
text-align: center;
a {
color: oklch(from var(--a-color) 0.9 calc(c * 0.3) h);
}
.ti {
position: relative;
top: 0.05rem;
}
}
.sidebar {
width: 15rem;
max-height: 100vh;
overflow: auto;
border-right: solid 1px var(--default-border-color);
}
.toolbar {
align-items: center;
border-bottom: solid 1px var(--default-border-color);
display: grid;
grid-template: 'utilities user' 1fr / 1fr max-content;
.toolbar__page-actions {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -372,7 +393,7 @@ a {
padding: 0 12px; padding: 0 12px;
} }
.page-grid__toolbar-user { .toolbar__user {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -386,24 +407,13 @@ a {
--button-color: var(--button-color--secondary); --button-color: var(--button-color--secondary);
} }
} }
.page-grid__sidebar {
grid-area: sidebar;
width: 15rem;
max-height: 100vh;
overflow: auto;
border-right: solid 1px var(--default-border-color);
}
.page-grid__main {
grid-area: main;
overflow: auto;
}
} }
/* ======== Workspace Nav ======== */ /* ======== Workspace Nav ======== */
.workspace-nav { .workspace-nav {
padding: 16px;
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
margin: 0; margin: 0;
font-weight: 600; font-weight: 600;

View file

@ -3,6 +3,9 @@
:root { :root {
--table-header-border-color: var(--default-border-color); --table-header-border-color: var(--default-border-color);
--table-cell-border-color: oklch(from var(--default-border-color) calc(l * 1.15) c h); --table-cell-border-color: oklch(from var(--default-border-color) calc(l * 1.15) c h);
--cursor-outline-color: oklch(from var(--accent-color) 0.7 0.45 calc(h + 165));
--selection-background-color: oklch(from var(--accent-color) 0.94 0.05 calc(h + 165));
} }
/* ======== Toolbar ======== */ /* ======== Toolbar ======== */
@ -205,11 +208,11 @@
width: 100%; width: 100%;
&.table-viewer__cell-container--selected { &.table-viewer__cell-container--selected {
background: #07f3; background: var(--selection-background-color);
} }
&.table-viewer__cell-container--cursor { &.table-viewer__cell-container--cursor {
outline: 3px solid #37f; outline: 3px solid var(--cursor-outline-color);
outline-offset: -2px; outline-offset: -2px;
} }
} }
@ -314,10 +317,10 @@
.table-viewer__inserter-submit { .table-viewer__inserter-submit {
align-items: center; align-items: center;
border: dashed 1px var(--button-background--primary); border: dashed 1px var(--a-color);
border-bottom-right-radius: var(--default-border-radius--rounded-sm); border-bottom-right-radius: var(--default-border-radius--rounded-sm);
border-top-right-radius: var(--default-border-radius--rounded-sm); border-top-right-radius: var(--default-border-radius--rounded-sm);
color: var(--button-background--primary); color: var(--a-color);
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 0 var(--default-padding); padding: 0 var(--default-padding);

View file

@ -1,127 +0,0 @@
<!--
@component
TODO: Can this component be removed?
-->
<script lang="ts">
type CssClass = string | (string | false | null | undefined)[];
type Props = {
completions: string[];
popover_class?: CssClass;
search_input_class?: CssClass;
search_input_element?: HTMLInputElement;
search_value: string;
value: string;
};
let {
completions,
popover_class,
search_input_class,
search_input_element = $bindable(),
search_value = $bindable(),
value = $bindable(),
}: Props = $props();
let focused = $state(false);
let popover_element = $state<HTMLDivElement | undefined>();
// Hacky workaround because as of September 2025 implicit anchor association
// is still pretty broken, at least in Firefox.
let anchor_name = $state(`--anchor-${Math.floor(Math.random() * 1000000)}`);
function handle_component_focusin() {
focused = true;
popover_element?.showPopover();
}
function handle_component_focusout() {
focused = false;
setTimeout(() => {
// TODO: There's still an edge case, where a click with a
// mousedown-to-mouseup duration greater than the delay here will cause
// the popover to hide.
if (!focused) {
popover_element?.hidePopover();
}
}, 250);
}
</script>
<!--
Wrapping both the search input and the popover in a container element allows us
to capture
-->
<div
class="combobox__container"
onfocusin={handle_component_focusin}
onfocusout={handle_component_focusout}
style:anchor-name={anchor_name}
>
<input
bind:this={search_input_element}
bind:value={search_value}
class={search_input_class}
oninput={() => {
popover_element?.showPopover();
}}
onkeydown={(ev) => {
if (ev.key === "Escape") {
popover_element?.hidePopover();
}
}}
type="text"
/>
<div
bind:this={popover_element}
class={popover_class ?? "combobox__popover"}
popover="manual"
role="listbox"
style:position-anchor={anchor_name}
>
{#each completions as completion}
<button
aria-selected={value === completion}
class="combobox__completion"
onclick={() => {
value = completion;
search_input_element?.focus();
popover_element?.hidePopover();
}}
role="option"
type="button"
>
{completion}
</button>
{/each}
</div>
</div>
<style lang="scss">
.combobox {
&__popover {
&:popover-open {
// @include globals.popover;
left: anchor(left);
position: absolute;
top: anchor(bottom);
}
}
&__completion {
// @include globals.reset-button;
display: block;
padding: 0.5rem;
font-weight: normal;
text-align: left;
width: 100%;
&:hover,
&:focus {
background: #0000001f;
}
}
}
</style>

View file

@ -1,87 +0,0 @@
<svelte:options
customElement={{
props: {
identifier_hints: { attribute: "identifier-hints", type: "Array" },
portals: { type: "Array" },
value: { type: "Array" },
},
shadow: "none",
tag: "form-transitions-editor",
}}
/>
<!--
@component
Interactive island for configuring the form navigation that occurs when a user
submits a portal's form.
-->
<script lang="ts">
import { type PgExpressionAny } from "./expression.svelte";
import ExpressionEditor from "./expression-editor.webc.svelte";
type Props = {
identifier_hints: string[];
portals: {
id: string;
display_name: string;
}[];
value: {
condition?: PgExpressionAny;
dest?: string;
}[];
};
let {
identifier_hints = [],
portals = [],
value: initial_value = [],
}: Props = $props();
// Prop of this webc component cannot be bound to `<ExpressionEditor value>`
// without freezing up the child component.
let value = $state(initial_value);
function handle_add_transition_button_click() {
value = [...value, {}];
}
</script>
<div class="form-transitions-editor">
<ul class="form-transitions-editor__transitions-list">
{#each value as _, i}
<li class="form-transitions-editor__transition-item">
<div>
Continue to form:
<select name="dest" bind:value={value[i].dest}>
{#each portals as portal}
<option value={portal.id}>{portal.display_name}</option>
{/each}
</select>
</div>
<div>
if:
<ExpressionEditor
{identifier_hints}
bind:value={value[i].condition}
/>
<input
type="hidden"
name="condition"
value={JSON.stringify(value[i].condition)}
/>
</div>
</li>
{/each}
</ul>
<button
class="button--secondary"
onclick={handle_add_transition_button_click}
type="button"
>
Add Destination
</button>
<div>
If no destinations match, the user will be redirected to a success page.
</div>
</div>

View file

@ -1,55 +0,0 @@
<svelte:options
customElement={{
props: {
field_id: { attribute: "field-id" },
languages: { type: "Array" },
value: { type: "Object" },
},
shadow: "none",
tag: "i18n-textarea",
}}
/>
<!--
@component
A textbox allowing for input in multiple alternative languages, used in the
form editor. The `value` attribute is expected to be a JSON record mapping
language codes to their initial text contents. The `languages` attribute is
expected to be a JSON array containing objects with keys `"code"` and
`"locale_str"`.
Form values are exposed as textarea inputs named as:
`<FIELD_ID>.<LANGUAGE_CODE>`.
-->
<script lang="ts">
const DEFAULT_LANGUAGE = "eng";
type Props = {
field_id: string;
languages: {
code: string;
locale_str: string;
}[];
value: Record<string, string>;
};
let { field_id = "", languages = [], value = {} }: Props = $props();
let visible_language = $state(DEFAULT_LANGUAGE);
</script>
<div class="i18n-textarea">
<select bind:value={visible_language}>
{#each languages as { code, locale_str }}
<option value={code}>{locale_str}</option>
{/each}
</select>
{#each languages as { code }}
<textarea
name={`${field_id}.${code}`}
style:display={visible_language === code ? "block" : "none"}
>{value[code]}</textarea
>
{/each}
</div>