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