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