1use crate::admin::AuthAdminData;
2use crate::repositories::PostgresAuthUserRepository;
3use platform_core::AppContext;
4use platform_http::ApiOpenApiRouter;
5use platform_module::{
6 AdminAction, AdminActionDangerLevel, AdminActionInputField, AdminActionInputSchema,
7 AdminDeclarativeComponent, AdminDeclarativePage, AdminDeclarativeSection,
8 AdminDeclarativeSurface, AdminSchema, ConsoleArea, ConsoleContributionKind, ConsoleNavigation,
9 ConsolePackage, ConsoleSlot, ConsoleSlotContext, ConsoleSlotContextField,
10 ConsoleSlotContextFieldType, ConsoleSurface, ConsoleWorkspaceRef, EntitySchema, FieldSchema,
11 FieldType, LinkedBinding, LinkedHttpContribution, Module, ModuleHttpMethod, ModuleHttpRoute,
12 ModuleManifest,
13};
14use std::sync::Arc;
15
16pub const MODULE_NAME: &str = "auth";
17pub const AUTH_USERS_READ: &str = "auth.users.read";
18pub const AUTH_USERS_DETAIL_ACTIONS_SLOT: &str = "auth.users.detail.actions";
19pub const AUTH_USERS_DETAIL_ACTIONS_SLOT_VERSION: u32 = 1;
20
21pub fn http_routes() -> Vec<ModuleHttpRoute> {
22 vec![
23 ModuleHttpRoute {
24 method: ModuleHttpMethod::Post,
25 path: "/v1/auth/dev/sessions".to_owned(),
26 capability: None,
27 operation: None,
28 display_name: Some("Create Development Session".to_owned()),
29 story_title: Some("Development Auth Session".to_owned()),
30 },
31 ModuleHttpRoute {
32 method: ModuleHttpMethod::Post,
33 path: "/v1/auth/sessions/revoke".to_owned(),
34 capability: None,
35 operation: None,
36 display_name: Some("Revoke Session".to_owned()),
37 story_title: Some("Auth Session Revoked".to_owned()),
38 },
39 ]
40}
41
42pub fn user_schema() -> AdminSchema {
43 AdminSchema {
44 entities: vec![
45 EntitySchema {
46 name: "users".to_owned(),
47 label: "Users".to_owned(),
48 read_capability: AUTH_USERS_READ.to_owned(),
49 fields: vec![
50 FieldSchema {
51 name: "id".to_owned(),
52 label: "ID".to_owned(),
53 field_type: FieldType::String,
54 nullable: false,
55 },
56 FieldSchema {
57 name: "is_anonymous".to_owned(),
58 label: "Anonymous".to_owned(),
59 field_type: FieldType::Boolean,
60 nullable: false,
61 },
62 FieldSchema {
63 name: "device_id".to_owned(),
64 label: "Device".to_owned(),
65 field_type: FieldType::String,
66 nullable: true,
67 },
68 FieldSchema {
69 name: "created_at".to_owned(),
70 label: "Created".to_owned(),
71 field_type: FieldType::Timestamp,
72 nullable: false,
73 },
74 FieldSchema {
75 name: "disabled_at".to_owned(),
76 label: "Disabled".to_owned(),
77 field_type: FieldType::Timestamp,
78 nullable: true,
79 },
80 FieldSchema {
81 name: "disabled_reason".to_owned(),
82 label: "Reason".to_owned(),
83 field_type: FieldType::String,
84 nullable: true,
85 },
86 FieldSchema {
87 name: "disabled_until".to_owned(),
88 label: "Until".to_owned(),
89 field_type: FieldType::Timestamp,
90 nullable: true,
91 },
92 ],
93 },
94 EntitySchema {
95 name: "sessions".to_owned(),
96 label: "Sessions".to_owned(),
97 read_capability: AUTH_USERS_READ.to_owned(),
98 fields: vec![
99 FieldSchema {
100 name: "id".to_owned(),
101 label: "ID".to_owned(),
102 field_type: FieldType::String,
103 nullable: false,
104 },
105 FieldSchema {
106 name: "user_id".to_owned(),
107 label: "User".to_owned(),
108 field_type: FieldType::String,
109 nullable: false,
110 },
111 FieldSchema {
112 name: "device_id".to_owned(),
113 label: "Device".to_owned(),
114 field_type: FieldType::String,
115 nullable: true,
116 },
117 FieldSchema {
118 name: "client_ip".to_owned(),
119 label: "IP".to_owned(),
120 field_type: FieldType::String,
121 nullable: true,
122 },
123 FieldSchema {
124 name: "user_agent".to_owned(),
125 label: "User agent".to_owned(),
126 field_type: FieldType::String,
127 nullable: true,
128 },
129 FieldSchema {
130 name: "created_at".to_owned(),
131 label: "Created".to_owned(),
132 field_type: FieldType::Timestamp,
133 nullable: false,
134 },
135 FieldSchema {
136 name: "expires_at".to_owned(),
137 label: "Expires".to_owned(),
138 field_type: FieldType::Timestamp,
139 nullable: false,
140 },
141 FieldSchema {
142 name: "revoked_at".to_owned(),
143 label: "Revoked".to_owned(),
144 field_type: FieldType::Timestamp,
145 nullable: true,
146 },
147 ],
148 },
149 ],
150 }
151}
152
153pub fn admin_surface() -> AdminDeclarativeSurface {
154 AdminDeclarativeSurface {
155 pages: vec![AdminDeclarativePage {
156 name: "sessions".to_owned(),
157 label: "Sessions".to_owned(),
158 sections: vec![AdminDeclarativeSection {
159 name: "sessions".to_owned(),
160 label: "Sessions".to_owned(),
161 component: AdminDeclarativeComponent::EntityTable {
162 entity: "sessions".to_owned(),
163 },
164 }],
165 }],
166 actions: vec![
167 action_with_string_input(
168 "revoke_session",
169 "Revoke session",
170 "session_id",
171 "Session",
172 AdminActionDangerLevel::Medium,
173 ),
174 disable_user_action(),
175 action_with_string_input(
176 "enable_user",
177 "Enable user",
178 "user_id",
179 "User",
180 AdminActionDangerLevel::Low,
181 ),
182 ],
183 fallback_schema: Some(user_schema()),
184 }
185}
186
187fn action_with_string_input(
188 name: &str,
189 label: &str,
190 input_name: &str,
191 input_label: &str,
192 danger_level: AdminActionDangerLevel,
193) -> AdminAction {
194 AdminAction {
195 name: name.to_owned(),
196 label: label.to_owned(),
197 capability: AUTH_USERS_READ.to_owned(),
198 input_schema: Some(AdminActionInputSchema {
199 fields: vec![AdminActionInputField {
200 name: input_name.to_owned(),
201 label: input_label.to_owned(),
202 field_type: FieldType::String,
203 required: true,
204 description: None,
205 }],
206 }),
207 confirmation: None,
208 operation: None,
209 danger_level,
210 }
211}
212
213fn disable_user_action() -> AdminAction {
214 AdminAction {
215 name: "disable_user".to_owned(),
216 label: "Disable user".to_owned(),
217 capability: AUTH_USERS_READ.to_owned(),
218 input_schema: Some(AdminActionInputSchema {
219 fields: vec![
220 AdminActionInputField {
221 name: "user_id".to_owned(),
222 label: "User".to_owned(),
223 field_type: FieldType::String,
224 required: true,
225 description: None,
226 },
227 AdminActionInputField {
228 name: "reason".to_owned(),
229 label: "Reason".to_owned(),
230 field_type: FieldType::String,
231 required: false,
232 description: None,
233 },
234 AdminActionInputField {
235 name: "disabled_until".to_owned(),
236 label: "Until".to_owned(),
237 field_type: FieldType::Timestamp,
238 required: false,
239 description: Some("RFC3339 timestamp; omit for permanent".to_owned()),
240 },
241 ],
242 }),
243 confirmation: None,
244 operation: None,
245 danger_level: AdminActionDangerLevel::Medium,
246 }
247}
248
249fn auth_workspace() -> ConsoleWorkspaceRef {
250 ConsoleWorkspaceRef {
251 id: "auth".to_owned(),
252 label: "Auth".to_owned(),
253 icon: Some("shield".to_owned()),
254 }
255}
256
257pub fn console_surfaces() -> Vec<ConsoleSurface> {
258 vec![
259 ConsoleSurface {
260 name: "sessions".to_owned(),
261 label: "Sessions".to_owned(),
262 area: ConsoleArea::Data,
263 route: "/data/auth/sessions".to_owned(),
264 package: ConsolePackage {
265 name: "@lenso/auth-console".to_owned(),
266 export: "authConsoleModule".to_owned(),
267 },
268 icon: Some("shield".to_owned()),
269 required_capabilities: vec![AUTH_USERS_READ.to_owned()],
270 navigation: Some(ConsoleNavigation {
271 workspace: auth_workspace(),
272 group: None,
273 order: Some(50),
274 }),
275 },
276 ConsoleSurface {
277 name: "users".to_owned(),
278 label: "Users".to_owned(),
279 area: ConsoleArea::Data,
280 route: "/data/auth/users".to_owned(),
281 package: ConsolePackage {
282 name: "@lenso/auth-console".to_owned(),
283 export: "authConsoleModule".to_owned(),
284 },
285 icon: Some("shield".to_owned()),
286 required_capabilities: vec![AUTH_USERS_READ.to_owned()],
287 navigation: Some(ConsoleNavigation {
288 workspace: auth_workspace(),
289 group: None,
290 order: Some(60),
291 }),
292 },
293 ]
294}
295
296pub fn console_slots() -> Vec<ConsoleSlot> {
297 vec![ConsoleSlot {
298 id: AUTH_USERS_DETAIL_ACTIONS_SLOT.to_owned(),
299 version: AUTH_USERS_DETAIL_ACTIONS_SLOT_VERSION,
300 label: "User detail actions".to_owned(),
301 accepts: vec![ConsoleContributionKind::AdminAction],
302 context: vec![ConsoleSlotContext {
303 name: "selected_user".to_owned(),
304 fields: vec![ConsoleSlotContextField {
305 name: "id".to_owned(),
306 field_type: ConsoleSlotContextFieldType::String,
307 required: true,
308 }],
309 }],
310 }]
311}
312
313pub fn manifest() -> ModuleManifest {
314 ModuleManifest::builder(MODULE_NAME)
315 .capabilities(vec![AUTH_USERS_READ.to_owned()])
316 .http_routes(http_routes())
317 .declarative_admin(admin_surface())
318 .console(console_surfaces())
319 .console_slots(console_slots())
320 .build()
321}
322
323pub fn merge_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
324 base.merge(crate::routes::router())
325}
326
327pub fn binding() -> LinkedBinding {
328 LinkedBinding::builder()
329 .http(LinkedHttpContribution {
330 public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
331 merge: merge_http,
332 })
333 .build()
334}
335
336pub fn module(ctx: &AppContext) -> Module {
337 let repository = Arc::new(PostgresAuthUserRepository::new(ctx.db.clone()));
338 let admin = Arc::new(AuthAdminData::new(repository));
339 Module::linked(manifest(), binding())
340 .with_runtime_config(crate::config::RUNTIME_CONFIG.as_slice())
341 .with_admin_data(admin.clone())
342 .with_admin_actions(admin)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use platform_module::{ModuleManifestLintSeverity, ModuleSource, lint_module_manifest};
349
350 #[test]
351 fn manifest_declares_auth_user_anchor() {
352 let manifest = manifest();
353
354 assert_eq!(manifest.name, MODULE_NAME);
355 assert_eq!(manifest.capabilities, vec![AUTH_USERS_READ]);
356 assert_eq!(manifest.http_routes, http_routes());
357 assert_eq!(
358 manifest.admin,
359 Some(platform_module::AdminSurface::DeclarativeCustom(
360 admin_surface()
361 ))
362 );
363 assert_eq!(manifest.console, console_surfaces());
364 assert_eq!(manifest.console_slots, console_slots());
365
366 let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
367 assert!(
368 lints
369 .iter()
370 .all(|lint| lint.severity == ModuleManifestLintSeverity::Ok),
371 "auth manifest should not have warning/error lints: {lints:?}"
372 );
373 }
374}