1use crate::admin_schema::{AdminSchema, FieldType};
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
12#[serde(tag = "kind", rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum AdminSurface {
15 Schema(AdminSchema),
17 DeclarativeCustom(AdminDeclarativeSurface),
19 EmbeddedCustom(AdminEmbeddedSurface),
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
24pub struct AdminDeclarativeSurface {
25 #[serde(default)]
26 pub pages: Vec<AdminDeclarativePage>,
27 #[serde(default)]
28 pub actions: Vec<AdminAction>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub fallback_schema: Option<AdminSchema>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
34pub struct AdminDeclarativePage {
35 pub name: String,
36 pub label: String,
37 #[serde(default)]
38 pub sections: Vec<AdminDeclarativeSection>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
42pub struct AdminDeclarativeSection {
43 pub name: String,
44 pub label: String,
45 pub component: AdminDeclarativeComponent,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum AdminDeclarativeComponent {
52 MetricStrip {
53 #[serde(default)]
54 metrics: Vec<AdminMetricBinding>,
55 },
56 EntityTable {
57 entity: String,
58 },
59 EntityDetail {
60 entity: String,
61 },
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
65pub struct AdminMetricBinding {
66 pub label: String,
67 pub value_path: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
71pub struct AdminAction {
72 pub name: String,
73 pub label: String,
74 pub capability: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub input_schema: Option<AdminActionInputSchema>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub confirmation: Option<AdminActionConfirmation>,
79 #[serde(default, skip_serializing_if = "AdminActionDangerLevel::is_low")]
80 pub danger_level: AdminActionDangerLevel,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
84pub struct AdminActionInputSchema {
85 #[serde(default)]
86 pub fields: Vec<AdminActionInputField>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
90pub struct AdminActionInputField {
91 pub name: String,
92 pub label: String,
93 pub field_type: FieldType,
94 #[serde(default)]
95 pub required: bool,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub description: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
101pub struct AdminActionConfirmation {
102 pub message: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub required_phrase: Option<String>,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
108#[serde(rename_all = "snake_case")]
109#[non_exhaustive]
110pub enum AdminActionDangerLevel {
111 #[default]
112 Low,
113 Medium,
114 High,
115}
116
117impl AdminActionDangerLevel {
118 fn is_low(&self) -> bool {
119 matches!(self, Self::Low)
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
124pub struct AdminEmbeddedSurface {
125 pub runtime: AdminEmbeddedRuntime,
126 pub entry: AdminEmbeddedEntry,
127 pub sandbox: AdminSandboxPolicy,
128 #[serde(default)]
129 pub permissions: Vec<AdminPermission>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub fallback_schema: Option<AdminSchema>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
135#[serde(rename_all = "snake_case")]
136#[non_exhaustive]
137pub enum AdminEmbeddedRuntime {
138 Iframe,
139 Wasm,
140 JsBundle,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
144#[serde(tag = "kind", rename_all = "snake_case")]
145#[non_exhaustive]
146pub enum AdminEmbeddedEntry {
147 Url {
148 url: String,
149 #[serde(default)]
150 allowed_origins: Vec<String>,
151 },
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
155pub struct AdminSandboxPolicy {
156 #[serde(default)]
157 pub allow_scripts: bool,
158 #[serde(default)]
159 pub allow_forms: bool,
160 #[serde(default)]
161 pub allow_popups: bool,
162 #[serde(default)]
163 pub allow_same_origin: bool,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
167#[serde(tag = "kind", rename_all = "snake_case")]
168#[non_exhaustive]
169pub enum AdminPermission {
170 ReadEntity { entity: String },
171 InvokeAction { action: String },
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::admin_schema::{AdminSchema, EntitySchema};
178
179 fn fallback_schema() -> AdminSchema {
180 AdminSchema {
181 entities: vec![EntitySchema {
182 name: "contacts".to_owned(),
183 label: "Contacts".to_owned(),
184 fields: vec![],
185 read_capability: "remote_crm.contacts.read".to_owned(),
186 }],
187 }
188 }
189
190 #[test]
191 fn declarative_custom_surface_round_trips_through_json() {
192 let surface = AdminSurface::DeclarativeCustom(AdminDeclarativeSurface {
193 pages: vec![AdminDeclarativePage {
194 name: "dashboard".to_owned(),
195 label: "Dashboard".to_owned(),
196 sections: vec![AdminDeclarativeSection {
197 name: "contacts".to_owned(),
198 label: "Contacts".to_owned(),
199 component: AdminDeclarativeComponent::EntityTable {
200 entity: "contacts".to_owned(),
201 },
202 }],
203 }],
204 actions: vec![AdminAction {
205 name: "sync_contacts".to_owned(),
206 label: "Sync contacts".to_owned(),
207 capability: "remote_crm.contacts.sync".to_owned(),
208 input_schema: None,
209 confirmation: None,
210 danger_level: AdminActionDangerLevel::Low,
211 }],
212 fallback_schema: Some(fallback_schema()),
213 });
214
215 let json = serde_json::to_string(&surface).expect("serialize");
216 assert!(
217 json.contains(r#""kind":"declarative_custom""#),
218 "got {json}"
219 );
220 let back: AdminSurface = serde_json::from_str(&json).expect("deserialize");
221 assert_eq!(surface, back);
222 }
223
224 #[test]
225 fn embedded_custom_surface_round_trips_through_json() {
226 let surface = AdminSurface::EmbeddedCustom(AdminEmbeddedSurface {
227 runtime: AdminEmbeddedRuntime::Iframe,
228 entry: AdminEmbeddedEntry::Url {
229 url: "https://crm.example.test/admin".to_owned(),
230 allowed_origins: vec!["https://crm.example.test".to_owned()],
231 },
232 sandbox: AdminSandboxPolicy {
233 allow_scripts: true,
234 allow_forms: false,
235 allow_popups: false,
236 allow_same_origin: false,
237 },
238 permissions: vec![AdminPermission::ReadEntity {
239 entity: "contacts".to_owned(),
240 }],
241 fallback_schema: Some(fallback_schema()),
242 });
243
244 let json = serde_json::to_string(&surface).expect("serialize");
245 assert!(json.contains(r#""kind":"embedded_custom""#), "got {json}");
246 assert!(json.contains(r#""runtime":"iframe""#), "got {json}");
247 let back: AdminSurface = serde_json::from_str(&json).expect("deserialize");
248 assert_eq!(surface, back);
249 }
250}