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