@valentinkolb/cloud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,966 @@
1
+ import { sql } from "bun";
2
+ import { accountLifecycle } from "../account-lifecycle";
3
+ import { lifecycleJobs } from "../account-lifecycle/scheduler";
4
+ import { logger, logging, type LogEntry } from "../logging";
5
+ import { notifications } from "../notifications";
6
+ import { getFreeIpaConfig } from "../freeipa-config";
7
+ import * as settings from "../settings";
8
+ import { renderTemplate } from "../settings/templates";
9
+ import { isUniqueViolation } from "../postgres";
10
+ import { providers } from "../providers";
11
+ import * as users from "./users";
12
+ import * as groups from "./groups";
13
+ import * as entities from "./entities";
14
+ import type {
15
+ BaseGroup,
16
+ BaseUser,
17
+ EntityKind,
18
+ EntityListItem,
19
+ GroupMember,
20
+ MutationResult,
21
+ User,
22
+ UserProfile,
23
+ UserProvider,
24
+ } from "../../contracts/shared";
25
+ import { dates } from "../../shared";
26
+ import { err, fail, ok, paginate, type PageParams, type Paginated, type Result, type ServiceError } from "../../server/services";
27
+
28
+ type CreateUserInput =
29
+ | {
30
+ provider: "ipa";
31
+ email: string;
32
+ givenname: string;
33
+ sn: string;
34
+ displayName?: string;
35
+ autoSendNotification?: boolean;
36
+ requestId?: string;
37
+ }
38
+ | {
39
+ provider: "local";
40
+ profile: UserProfile;
41
+ admin?: boolean;
42
+ email: string;
43
+ givenname: string;
44
+ sn: string;
45
+ displayName?: string;
46
+ autoSendNotification?: boolean;
47
+ requestId?: string;
48
+ };
49
+
50
+ type DbRow = Record<string, unknown>;
51
+ type MutationErrorStatus = Extract<MutationResult, { ok: false }>["status"];
52
+
53
+ export type AccountRequestStatus = "pending" | "completed" | "denied";
54
+ export type AccountRequestScope = "open" | "processed" | "all";
55
+
56
+ export type AccountRequest = {
57
+ id: string;
58
+ userId: string | null;
59
+ email: string;
60
+ firstName: string;
61
+ lastName: string;
62
+ displayName: string | null;
63
+ phone: string | null;
64
+ comment: string | null;
65
+ status: AccountRequestStatus;
66
+ createdAt: string;
67
+ };
68
+
69
+ export type AccountsDashboardSummary = {
70
+ ipaAccountsTotal: number;
71
+ localAccountsTotal: number;
72
+ localUserAccountsTotal: number;
73
+ localGuestAccountsTotal: number;
74
+ groupsTotal: number;
75
+ ipaGroupsTotal: number;
76
+ localGroupsTotal: number;
77
+ openRequests: number;
78
+ ipaExpiring30d: number;
79
+ localUserExpiring30d: number;
80
+ localGuestExpiring30d: number;
81
+ overdueLocalGuests: number;
82
+ reminderErrors: number;
83
+ deletedLast7d: number;
84
+ runHealthWindow: number;
85
+ recentSyncRuns: number;
86
+ recentSyncRunsWithFailures: number;
87
+ recentDemotionRuns: number;
88
+ recentDemotionRunsWithFailures: number;
89
+ recentReminderRuns: number;
90
+ recentReminderRunsWithFailures: number;
91
+ lastSync: {
92
+ createdAt: string;
93
+ users: number;
94
+ groups: number;
95
+ } | null;
96
+ };
97
+
98
+ const ACTIVITY_SOURCES = [
99
+ "auth:ipa:sync",
100
+ "auth:ipa:backfill",
101
+ "auth:local-user:backfill",
102
+ "auth:guest:backfill",
103
+ "auth:reminder:daily",
104
+ "auth:guest:cleanup",
105
+ "auth:lifecycle:scheduler",
106
+ ] as const;
107
+
108
+ const paginateItems = <T>(items: T[], pagination?: PageParams): Paginated<T> => {
109
+ if (!pagination) {
110
+ return {
111
+ items,
112
+ page: 1,
113
+ perPage: items.length,
114
+ total: items.length,
115
+ hasNext: false,
116
+ };
117
+ }
118
+
119
+ const { page, perPage, offset } = paginate(pagination);
120
+ const pagedItems = items.slice(offset, offset + perPage);
121
+ return {
122
+ items: pagedItems,
123
+ page,
124
+ perPage,
125
+ total: items.length,
126
+ hasNext: page * perPage < items.length,
127
+ };
128
+ };
129
+
130
+ const toServiceError = (status: MutationErrorStatus, message: string): ServiceError => {
131
+ if (status === 400) return err.badInput(message);
132
+ if (status === 401) return err.unauthenticated(message);
133
+ if (status === 403) return err.forbidden(message);
134
+ if (status === 404) return { code: "NOT_FOUND", message, status };
135
+ if (status === 409) return { code: "CONFLICT", message, status };
136
+ return err.internal(message);
137
+ };
138
+
139
+ const fromMutationResult = <T>(result: MutationResult<T>): Result<T> => {
140
+ if (result.ok) return ok(result.data);
141
+ return fail(toServiceError(result.status, result.error));
142
+ };
143
+
144
+ const buildFreeipaWelcomeEmailHtml = async (config: { uid: string; temporaryPassword: string; accountExpires: string | null }) => {
145
+ const template = await settings.get<string>("mail.user_welcome_freeipa");
146
+ const contactEmail = await settings.get<string>("app.contact_email");
147
+ const rawAppUrl = await settings.get<string>("app.url");
148
+ const baseUrl = /^https?:\/\//.test(rawAppUrl) ? rawAppUrl : `https://${rawAppUrl}`;
149
+ const loginUrl = `${baseUrl}/auth/login?method=ipa&ipa-uid=${encodeURIComponent(config.uid)}`;
150
+ const expiry = config.accountExpires ? dates.formatDate(config.accountExpires) : "";
151
+
152
+ return renderTemplate(template, {
153
+ USERNAME: config.uid,
154
+ PASSWORD: config.temporaryPassword,
155
+ EXPIRY: expiry,
156
+ LOGIN_URL: loginUrl,
157
+ CONTACT_EMAIL: contactEmail,
158
+ APP_NAME: await settings.get<string>("app.name"),
159
+ });
160
+ };
161
+
162
+ const buildLocalWelcomeEmailHtml = async (config: { email: string; accountExpires: string | null }) => {
163
+ const template = await settings.get<string>("mail.user_welcome_local");
164
+ const contactEmail = await settings.get<string>("app.contact_email");
165
+ const rawAppUrl = await settings.get<string>("app.url");
166
+ const baseUrl = /^https?:\/\//.test(rawAppUrl) ? rawAppUrl : `https://${rawAppUrl}`;
167
+ const loginUrl = `${baseUrl}/auth/login`;
168
+ const expiry = config.accountExpires ? dates.formatDate(config.accountExpires) : "";
169
+
170
+ return renderTemplate(template, {
171
+ EMAIL: config.email,
172
+ EXPIRY: expiry,
173
+ LOGIN_URL: loginUrl,
174
+ CONTACT_EMAIL: contactEmail,
175
+ APP_NAME: await settings.get<string>("app.name"),
176
+ });
177
+ };
178
+
179
+ const mapAccountRequestRow = (row: DbRow): AccountRequest => ({
180
+ id: row.id as string,
181
+ userId: row.user_id as string | null,
182
+ email: (row.email as string) ?? "",
183
+ firstName: (row.first_name as string) ?? "",
184
+ lastName: (row.last_name as string) ?? "",
185
+ displayName: (row.display_name as string) ?? null,
186
+ phone: (row.phone as string) ?? null,
187
+ comment: row.comment as string | null,
188
+ status: row.status as AccountRequestStatus,
189
+ createdAt: (row.created_at as Date).toISOString(),
190
+ });
191
+
192
+ const mapSummary = (row: DbRow): AccountsDashboardSummary => {
193
+ const lastSyncCreatedAt = row.last_sync_created_at as Date | null;
194
+
195
+ return {
196
+ ipaAccountsTotal: Number(row.ipa_accounts_total ?? 0),
197
+ localAccountsTotal: Number(row.local_accounts_total ?? 0),
198
+ localUserAccountsTotal: Number(row.local_user_accounts_total ?? 0),
199
+ localGuestAccountsTotal: Number(row.local_guest_accounts_total ?? 0),
200
+ groupsTotal: Number(row.groups_total ?? 0),
201
+ ipaGroupsTotal: Number(row.ipa_groups_total ?? 0),
202
+ localGroupsTotal: Number(row.local_groups_total ?? 0),
203
+ openRequests: Number(row.open_requests ?? 0),
204
+ ipaExpiring30d: Number(row.ipa_expiring_30d ?? 0),
205
+ localUserExpiring30d: Number(row.local_user_expiring_30d ?? 0),
206
+ localGuestExpiring30d: Number(row.local_guest_expiring_30d ?? 0),
207
+ overdueLocalGuests: Number(row.overdue_local_guests ?? 0),
208
+ reminderErrors: Number(row.reminder_errors ?? 0),
209
+ deletedLast7d: Number(row.deleted_last_7d ?? 0),
210
+ runHealthWindow: Number(row.run_health_window ?? 10),
211
+ recentSyncRuns: Number(row.recent_sync_runs ?? 0),
212
+ recentSyncRunsWithFailures: Number(row.recent_sync_runs_with_failures ?? 0),
213
+ recentDemotionRuns: Number(row.recent_demotion_runs ?? 0),
214
+ recentDemotionRunsWithFailures: Number(row.recent_demotion_runs_with_failures ?? 0),
215
+ recentReminderRuns: Number(row.recent_reminder_runs ?? 0),
216
+ recentReminderRunsWithFailures: Number(row.recent_reminder_runs_with_failures ?? 0),
217
+ lastSync: lastSyncCreatedAt
218
+ ? {
219
+ createdAt: lastSyncCreatedAt.toISOString(),
220
+ users: Number(row.last_sync_users ?? 0),
221
+ groups: Number(row.last_sync_groups ?? 0),
222
+ }
223
+ : null,
224
+ };
225
+ };
226
+
227
+ const appLog = logger("accounts:app");
228
+
229
+ const buildAccountRequestWhereClause = (config: {
230
+ access: { userId: string; isAdmin: boolean };
231
+ filter?: { status?: AccountRequestStatus; scope?: AccountRequestScope };
232
+ }) => {
233
+ if (!config.access.isAdmin) {
234
+ return sql`r.user_id = ${config.access.userId}::uuid AND r.status = 'pending'`;
235
+ }
236
+
237
+ if (config.filter?.status) {
238
+ return sql`r.status = ${config.filter.status}`;
239
+ }
240
+
241
+ const scope = config.filter?.scope ?? "open";
242
+ if (scope === "processed") return sql`r.status IN ('completed', 'denied')`;
243
+ if (scope === "all") return sql`TRUE`;
244
+ return sql`r.status = 'pending'`;
245
+ };
246
+
247
+ export const accountsAppService = {
248
+ user: {
249
+ list: async (config: {
250
+ pagination?: PageParams;
251
+ filter?: { search?: string };
252
+ scope?: { ids?: string[]; uids?: string[]; provider?: UserProvider; profile?: UserProfile };
253
+ }): Promise<Paginated<BaseUser>> => {
254
+ const { page, perPage } = paginate(config.pagination);
255
+ const result = await users.list({
256
+ page,
257
+ perPage,
258
+ search: config.filter?.search,
259
+ ids: config.scope?.ids,
260
+ uids: config.scope?.uids,
261
+ provider: config.scope?.provider,
262
+ profile: config.scope?.profile,
263
+ });
264
+
265
+ return {
266
+ items: result.users,
267
+ page,
268
+ perPage,
269
+ total: result.total,
270
+ hasNext: result.pagination.has_next,
271
+ };
272
+ },
273
+ get: async (config: { id: string } | { uid: string }): Promise<User | null> => users.get(config),
274
+ getMinimal: async (config: { id: string } | { uid: string }) => users.getMinimal(config),
275
+ group: {
276
+ list: async (config: {
277
+ userId: string;
278
+ recursive?: boolean;
279
+ pagination?: PageParams;
280
+ filter?: { query?: string };
281
+ }): Promise<Paginated<string>> => {
282
+ const userGroups = await users.getGroups({
283
+ id: config.userId,
284
+ recursive: config.recursive,
285
+ });
286
+ const query = config.filter?.query?.trim().toLowerCase();
287
+ const filtered = query ? userGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : userGroups;
288
+ return paginateItems(filtered, config.pagination);
289
+ },
290
+ },
291
+ groupId: {
292
+ list: async (config: { userId: string; recursive?: boolean }): Promise<string[]> =>
293
+ users.getGroupIds({
294
+ id: config.userId,
295
+ recursive: config.recursive,
296
+ }),
297
+ },
298
+ managedGroup: {
299
+ list: async (config: {
300
+ userId: string;
301
+ recursive?: boolean;
302
+ pagination?: PageParams;
303
+ filter?: { query?: string };
304
+ }): Promise<Paginated<string>> => {
305
+ const managedGroups = await users.getManagedGroups({
306
+ id: config.userId,
307
+ recursive: config.recursive,
308
+ });
309
+ const query = config.filter?.query?.trim().toLowerCase();
310
+ const filtered = query ? managedGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : managedGroups;
311
+ return paginateItems(filtered, config.pagination);
312
+ },
313
+ },
314
+ managedGroupId: {
315
+ /**
316
+ * Use this for authorization checks — names are only unique per provider,
317
+ * so comparing names across providers can grant incorrect access.
318
+ */
319
+ list: async (config: { userId: string; recursive?: boolean }): Promise<string[]> =>
320
+ users.getManagedGroupIds({
321
+ id: config.userId,
322
+ recursive: config.recursive,
323
+ }),
324
+ },
325
+ create: async (config: { ipaSession?: string | null; data: CreateUserInput; processedBy: string }) => {
326
+ const createResult = fromMutationResult(
327
+ await users.create({
328
+ ipaSession: config.ipaSession,
329
+ data: {
330
+ ...config.data,
331
+ profile: config.data.provider === "ipa" ? "user" : config.data.profile,
332
+ admin: config.data.provider === "local" ? config.data.admin : undefined,
333
+ },
334
+ }),
335
+ );
336
+ if (!createResult.ok) return createResult;
337
+
338
+ const created = createResult.data;
339
+ const autoSend = config.data.autoSendNotification ?? true;
340
+ if (autoSend && created.user.mail) {
341
+ if (config.data.provider === "ipa" && created.temporaryPassword) {
342
+ const appName = await settings.get<string>("app.name");
343
+ await notifications.send({
344
+ type: "email",
345
+ recipient: created.user.mail,
346
+ subject: `Welcome to ${appName}`,
347
+ rawHtml: await buildFreeipaWelcomeEmailHtml({
348
+ uid: created.user.uid,
349
+ temporaryPassword: created.temporaryPassword,
350
+ accountExpires: created.user.accountExpires,
351
+ }),
352
+ autoSend,
353
+ });
354
+ } else if (config.data.provider === "local") {
355
+ const appName = await settings.get<string>("app.name");
356
+ await notifications.send({
357
+ type: "email",
358
+ recipient: created.user.mail,
359
+ subject: `Welcome to ${appName}`,
360
+ rawHtml: await buildLocalWelcomeEmailHtml({
361
+ email: created.user.mail,
362
+ accountExpires: created.user.accountExpires,
363
+ }),
364
+ autoSend,
365
+ });
366
+ }
367
+ }
368
+
369
+ if (config.data.requestId) {
370
+ // Require the pending request to belong to the user we just created.
371
+ // Without the user_id match an admin could pass any request ID and
372
+ // silently "complete" an unrelated request. Fail loudly on zero-match
373
+ // instead of ignoring it — the caller asked us to close this request.
374
+ const matched = await sql`
375
+ UPDATE auth.account_requests
376
+ SET status = 'completed', processed_at = now(), processed_by = ${config.processedBy}
377
+ WHERE id = ${config.data.requestId}
378
+ AND user_id = ${created.user.id}::uuid
379
+ AND status = 'pending'
380
+ RETURNING id
381
+ `;
382
+ if (matched.length === 0) {
383
+ appLog.warn("Account request completion did not match", {
384
+ requestId: config.data.requestId,
385
+ createdUserId: created.user.id,
386
+ processedBy: config.processedBy,
387
+ });
388
+ return fail(err.badInput("Account request not found, already processed, or not owned by the created user"));
389
+ }
390
+ }
391
+
392
+ return ok({
393
+ id: created.user.id,
394
+ uid: created.user.uid,
395
+ accountExpires: created.user.accountExpires,
396
+ notificationSent: autoSend,
397
+ });
398
+ },
399
+ update: async (config: { ipaSession?: string | null; id: string; data: Parameters<typeof users.update>[0]["data"] }) =>
400
+ fromMutationResult(await users.update(config)),
401
+ resetPassword: async (config: { ipaSession: string; id: string }) =>
402
+ fromMutationResult(await users.resetPassword(config)),
403
+ setExpiry: async (config: { ipaSession?: string | null; id: string; expiryDate: string | null }) =>
404
+ fromMutationResult(await users.setExpiry(config)),
405
+ setProfile: async (config: { id: string; profile: UserProfile }) =>
406
+ fromMutationResult(await users.setProfile(config)),
407
+ setAdmin: async (config: { id: string; admin: boolean }) =>
408
+ fromMutationResult(await users.setAdmin(config)),
409
+ switchProvider: async (config: { ipaSession: string; id: string; provider: UserProvider }) =>
410
+ fromMutationResult(await users.switchProvider(config)),
411
+ demoteToGuest: async (config: { ipaSession: string; id: string; actor: { userId: string; uid: string } }) =>
412
+ fromMutationResult(await users.demoteToGuest(config)),
413
+ sendLoginLink: async (config: { id: string }) => fromMutationResult(await users.sendLoginLink(config)),
414
+ createLoginToken: async (config: { id: string }) => fromMutationResult(await users.createLoginToken(config)),
415
+ remove: async (config: { ipaSession?: string | null; id: string; actor: { userId: string; uid: string } }) =>
416
+ fromMutationResult(await users.remove(config)),
417
+
418
+ /**
419
+ * Change an IPA user's own password. Verifies the current password via
420
+ * `providers.ipa.auth.login` and issues the change through the session
421
+ * returned by verification. Keeps this logic inside core — the accounts
422
+ * admin app is UI only and must not dispatch on provider or speak to
423
+ * FreeIPA directly.
424
+ */
425
+ changeOwnPassword: async (config: {
426
+ user: User;
427
+ currentPassword: string;
428
+ newPassword: string;
429
+ }): Promise<Result<void>> => {
430
+ if (!(await getFreeIpaConfig()).enabled) return fail(err.badInput("FreeIPA is disabled."));
431
+ if (config.user.provider !== "ipa") {
432
+ return fail(err.badInput("Password change is only available for IPA accounts."));
433
+ }
434
+
435
+ const verify = await providers.ipa.auth.login(config.user.uid, config.currentPassword);
436
+ if (verify.status !== "success") {
437
+ return fail(err.unauthenticated("Current password is incorrect."));
438
+ }
439
+
440
+ const result = await providers.ipa.auth.changePassword({
441
+ ipaSession: verify.session,
442
+ uid: config.user.uid,
443
+ newPassword: config.newPassword,
444
+ });
445
+ if (!result.ok) return fail(toServiceError(result.status, result.error));
446
+ return ok(undefined);
447
+ },
448
+
449
+ /**
450
+ * Self-delete for the current user. Only guest profiles may self-delete;
451
+ * callers must enforce that before calling. Dispatches to the correct
452
+ * provider internally — callers should not branch on provider themselves.
453
+ */
454
+ removeSelf: async (config: { user: User; ipaSession: string | null }): Promise<Result<void>> => {
455
+ const actor = { userId: config.user.id, uid: config.user.uid };
456
+ if (config.user.provider === "ipa") {
457
+ if (!config.ipaSession) return fail(err.unauthenticated("IPA session required."));
458
+ return fromMutationResult(
459
+ await providers.ipa.users.remove({ ipaSession: config.ipaSession, id: config.user.id, actor }),
460
+ );
461
+ }
462
+ return fromMutationResult(await providers.local.users.remove({ id: config.user.id, actor }));
463
+ },
464
+ },
465
+
466
+ group: {
467
+ list: async (config: {
468
+ pagination?: PageParams;
469
+ filter?: { search?: string };
470
+ scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
471
+ }): Promise<Paginated<BaseGroup>> => {
472
+ const { page, perPage } = paginate(config.pagination);
473
+ const result = await groups.list({
474
+ page,
475
+ perPage,
476
+ search: config.filter?.search,
477
+ userId: config.scope?.userId,
478
+ scope: config.scope?.mode,
479
+ ids: config.scope?.ids,
480
+ provider: config.scope?.provider,
481
+ });
482
+
483
+ return {
484
+ items: result.groups,
485
+ page,
486
+ perPage,
487
+ total: result.total,
488
+ hasNext: result.pagination.hasNext,
489
+ };
490
+ },
491
+ get: async (config: { id: string }) => groups.get(config),
492
+ member: {
493
+ list: async (config: {
494
+ id: string;
495
+ recursive?: boolean;
496
+ pagination?: PageParams;
497
+ filter?: { query?: string; type?: GroupMember["type"] };
498
+ }): Promise<Paginated<GroupMember>> => {
499
+ const members = await groups.getMembers({
500
+ id: config.id,
501
+ recursive: config.recursive,
502
+ });
503
+ const query = config.filter?.query?.trim().toLowerCase();
504
+ const type = config.filter?.type;
505
+ const filtered = members.filter((member) => {
506
+ if (type && member.type !== type) return false;
507
+ if (!query) return true;
508
+ const id = member.id.toLowerCase();
509
+ const displayName = (member.displayName ?? "").toLowerCase();
510
+ return id.includes(query) || displayName.includes(query);
511
+ });
512
+ return paginateItems(filtered, config.pagination);
513
+ },
514
+ add: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; userId?: string; groupId?: string }) =>
515
+ fromMutationResult(
516
+ await groups.addMember({
517
+ ipaSession: config.ipaSession,
518
+ id: config.id,
519
+ provider: config.provider,
520
+ user: config.userId,
521
+ group: config.groupId,
522
+ }),
523
+ ),
524
+ remove: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; userId?: string; groupId?: string }) =>
525
+ fromMutationResult(
526
+ await groups.removeMember({
527
+ ipaSession: config.ipaSession,
528
+ id: config.id,
529
+ provider: config.provider,
530
+ user: config.userId,
531
+ group: config.groupId,
532
+ }),
533
+ ),
534
+ },
535
+ manager: {
536
+ list: async (config: {
537
+ id: string;
538
+ pagination?: PageParams;
539
+ filter?: { query?: string; type?: GroupMember["type"] };
540
+ }): Promise<Paginated<GroupMember>> => {
541
+ const managers = await groups.getManagers({ id: config.id });
542
+ const query = config.filter?.query?.trim().toLowerCase();
543
+ const type = config.filter?.type;
544
+ const filtered = managers.filter((manager) => {
545
+ if (type && manager.type !== type) return false;
546
+ if (!query) return true;
547
+ const id = manager.id.toLowerCase();
548
+ const displayName = (manager.displayName ?? "").toLowerCase();
549
+ return id.includes(query) || displayName.includes(query);
550
+ });
551
+ return paginateItems(filtered, config.pagination);
552
+ },
553
+ add: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; userId?: string; groupId?: string }) =>
554
+ fromMutationResult(
555
+ await groups.addManager({
556
+ ipaSession: config.ipaSession,
557
+ id: config.id,
558
+ provider: config.provider,
559
+ user: config.userId,
560
+ group: config.groupId,
561
+ }),
562
+ ),
563
+ remove: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; userId?: string; groupId?: string }) =>
564
+ fromMutationResult(
565
+ await groups.removeManager({
566
+ ipaSession: config.ipaSession,
567
+ id: config.id,
568
+ provider: config.provider,
569
+ user: config.userId,
570
+ group: config.groupId,
571
+ }),
572
+ ),
573
+ },
574
+ parent: {
575
+ list: async (config: {
576
+ id: string;
577
+ recursive?: boolean;
578
+ pagination?: PageParams;
579
+ filter?: { query?: string };
580
+ }): Promise<Paginated<string>> => {
581
+ const parentGroups = await groups.getParents({
582
+ id: config.id,
583
+ recursive: config.recursive,
584
+ });
585
+ const query = config.filter?.query?.trim().toLowerCase();
586
+ const filtered = query ? parentGroups.filter((groupId) => groupId.toLowerCase().includes(query)) : parentGroups;
587
+ return paginateItems(filtered, config.pagination);
588
+ },
589
+ },
590
+ managedGroup: {
591
+ list: async (config: { id: string; pagination?: PageParams; filter?: { query?: string } }): Promise<Paginated<string>> => {
592
+ const managedGroups = await groups.getManagedGroups({ id: config.id });
593
+ const query = config.filter?.query?.trim().toLowerCase();
594
+ const filtered = query ? managedGroups.filter((groupId) => groupId.toLowerCase().includes(query)) : managedGroups;
595
+ return paginateItems(filtered, config.pagination);
596
+ },
597
+ },
598
+ create: async (config: { ipaSession?: string | null; provider: UserProvider; name: string; description?: string; posix?: boolean }) =>
599
+ fromMutationResult(await groups.create(config)),
600
+ update: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider; description: string }) =>
601
+ fromMutationResult(await groups.update(config)),
602
+ remove: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider }) =>
603
+ fromMutationResult(await groups.remove(config)),
604
+ makePosix: async (config: { ipaSession?: string | null; id: string; provider?: UserProvider }) =>
605
+ fromMutationResult(await groups.makePosix(config)),
606
+ },
607
+ entity: {
608
+ list: async (config: {
609
+ pagination?: PageParams;
610
+ search?: string;
611
+ kinds?: EntityKind[];
612
+ provider?: UserProvider;
613
+ profile?: UserProfile;
614
+ excludeUserIds?: string[];
615
+ excludeGroupIds?: string[];
616
+ userMemberOfGroupIds?: string[];
617
+ memberOfGroupId?: string;
618
+ managerOfGroupId?: string;
619
+ parentGroupId?: string;
620
+ managedByUserId?: string;
621
+ recursive?: boolean;
622
+ }): Promise<Paginated<EntityListItem>> => {
623
+ const { page, perPage } = paginate(config.pagination);
624
+ const result = await entities.list({
625
+ search: config.search,
626
+ kinds: config.kinds,
627
+ provider: config.provider,
628
+ profile: config.profile,
629
+ excludeUserIds: config.excludeUserIds,
630
+ excludeGroupIds: config.excludeGroupIds,
631
+ userMemberOfGroupIds: config.userMemberOfGroupIds,
632
+ memberOfGroupId: config.memberOfGroupId,
633
+ managerOfGroupId: config.managerOfGroupId,
634
+ parentGroupId: config.parentGroupId,
635
+ managedByUserId: config.managedByUserId,
636
+ recursive: config.recursive,
637
+ page,
638
+ perPage,
639
+ });
640
+
641
+ return {
642
+ items: result.items,
643
+ page,
644
+ perPage,
645
+ total: result.total,
646
+ hasNext: result.pagination.hasNext,
647
+ };
648
+ },
649
+ },
650
+
651
+ accountRequest: {
652
+ list: async (config: {
653
+ access: { userId: string; isAdmin: boolean };
654
+ pagination?: PageParams;
655
+ filter?: { status?: AccountRequestStatus; scope?: AccountRequestScope };
656
+ }): Promise<Paginated<AccountRequest>> => {
657
+ const { page, perPage, offset } = paginate(config.pagination);
658
+ const where = buildAccountRequestWhereClause(config);
659
+ const rows = await sql`
660
+ SELECT r.id, r.user_id, u.mail AS email, u.given_name AS first_name, u.sn AS last_name,
661
+ u.display_name, r.phone, r.comment, r.status, r.created_at
662
+ FROM auth.account_requests r
663
+ JOIN auth.users u ON u.id = r.user_id
664
+ WHERE ${where}
665
+ ORDER BY r.created_at DESC
666
+ LIMIT ${perPage}
667
+ OFFSET ${offset}
668
+ `;
669
+
670
+ const totalRows = await sql`
671
+ SELECT COUNT(*)::int AS total
672
+ FROM auth.account_requests r
673
+ WHERE ${where}
674
+ `;
675
+
676
+ const total = totalRows[0]?.total ?? 0;
677
+ return {
678
+ items: rows.map(mapAccountRequestRow),
679
+ page,
680
+ perPage,
681
+ total,
682
+ hasNext: page * perPage < total,
683
+ };
684
+ },
685
+ get: async (config: { id: string; access: { userId: string; isAdmin: boolean } }) => {
686
+ const rows: DbRow[] = await sql`
687
+ SELECT r.id, r.user_id, u.mail AS email, u.given_name AS first_name, u.sn AS last_name,
688
+ u.display_name, r.phone, r.comment, r.status, r.created_at
689
+ FROM auth.account_requests r
690
+ JOIN auth.users u ON u.id = r.user_id
691
+ WHERE r.id = ${config.id}
692
+ `;
693
+
694
+ if (rows.length === 0) {
695
+ return fail(err.notFound("Request"));
696
+ }
697
+
698
+ const request = rows[0]!;
699
+ if (!config.access.isAdmin && request.user_id !== config.access.userId) {
700
+ return fail(err.forbidden("Access denied"));
701
+ }
702
+
703
+ return ok(mapAccountRequestRow(request));
704
+ },
705
+ getPendingForUser: async (config: { userId: string }): Promise<{ id: string; createdAt: Date } | null> => {
706
+ const rows: DbRow[] = await sql`
707
+ SELECT id, created_at FROM auth.account_requests
708
+ WHERE user_id = ${config.userId} AND status = 'pending'
709
+ LIMIT 1
710
+ `;
711
+ if (rows.length === 0) return null;
712
+ return {
713
+ id: rows[0]!.id as string,
714
+ createdAt: rows[0]!.created_at as Date,
715
+ };
716
+ },
717
+ create: async (config: { user: Pick<User, "id" | "mail" | "provider">; data: { phone?: string; comment?: string; acceptedAgb: true } }) => {
718
+ if (!(await getFreeIpaConfig()).enabled) {
719
+ return fail(err.badInput("FreeIPA is disabled"));
720
+ }
721
+ if (config.user.provider !== "local") {
722
+ return fail(err.forbidden("Only local accounts can request IPA-backed access"));
723
+ }
724
+ if (!config.user.mail) {
725
+ return fail(err.badInput("Your account has no email address"));
726
+ }
727
+
728
+ const existingRows: DbRow[] = await sql`
729
+ SELECT id FROM auth.account_requests
730
+ WHERE user_id = ${config.user.id} AND status = 'pending'
731
+ `;
732
+ if (existingRows.length > 0) {
733
+ return fail({
734
+ code: "CONFLICT",
735
+ message: "You already have a pending account request",
736
+ status: 409,
737
+ });
738
+ }
739
+
740
+ try {
741
+ const rows: DbRow[] = await sql`
742
+ INSERT INTO auth.account_requests (id, user_id, phone, comment, accepted_agb)
743
+ VALUES (gen_random_uuid(), ${config.user.id}, ${config.data.phone ?? null}, ${config.data.comment ?? null}, ${config.data.acceptedAgb})
744
+ RETURNING id
745
+ `;
746
+
747
+ return ok({
748
+ id: rows[0]!.id as string,
749
+ message: "FreeIPA account request submitted",
750
+ });
751
+ } catch (error) {
752
+ // Belt-and-suspenders: the partial unique index
753
+ // uq_account_requests_one_pending_per_user closes the race between the
754
+ // check above and the insert under concurrent submissions.
755
+ if (isUniqueViolation(error, "uq_account_requests_one_pending_per_user")) {
756
+ return fail({
757
+ code: "CONFLICT",
758
+ message: "You already have a pending account request",
759
+ status: 409,
760
+ });
761
+ }
762
+ throw error;
763
+ }
764
+ },
765
+ withdraw: async (config: { id: string; userId: string }) => {
766
+ const rows: DbRow[] = await sql`
767
+ SELECT id, user_id, status FROM auth.account_requests WHERE id = ${config.id}
768
+ `;
769
+
770
+ if (rows.length === 0) return fail(err.notFound("Request"));
771
+ const request = rows[0]!;
772
+ if (request.user_id !== config.userId) return fail(err.forbidden("Access denied"));
773
+ if (request.status !== "pending") return fail(err.forbidden("Only pending requests can be withdrawn"));
774
+
775
+ await sql`DELETE FROM auth.account_requests WHERE id = ${config.id}`;
776
+ return ok();
777
+ },
778
+ deny: async (config: { id: string; reason?: string; processedBy: string }) => {
779
+ const rows: DbRow[] = await sql`
780
+ SELECT r.id, r.user_id, r.status, u.mail AS email, u.given_name AS first_name
781
+ FROM auth.account_requests r
782
+ JOIN auth.users u ON u.id = r.user_id
783
+ WHERE r.id = ${config.id}
784
+ `;
785
+
786
+ if (rows.length === 0) return fail(err.notFound("Request"));
787
+
788
+ const request = rows[0]!;
789
+ if (request.status !== "pending") return fail(err.badInput("Only pending requests can be denied"));
790
+
791
+ await sql`
792
+ UPDATE auth.account_requests
793
+ SET status = 'denied', denied_reason = ${config.reason ?? null}, processed_at = now(), processed_by = ${config.processedBy}
794
+ WHERE id = ${config.id}
795
+ `;
796
+
797
+ if (config.reason) {
798
+ const template = await settings.get<string>("mail.account_request_denial");
799
+ const contactEmail = await settings.get<string>("app.contact_email");
800
+ const appName = await settings.get<string>("app.name");
801
+
802
+ await notifications.send({
803
+ type: "email",
804
+ recipient: request.email as string,
805
+ subject: "Account Request Update",
806
+ rawHtml: renderTemplate(template, {
807
+ FIRST_NAME: request.first_name as string,
808
+ REASON: config.reason,
809
+ CONTACT_EMAIL: contactEmail,
810
+ APP_NAME: appName,
811
+ }),
812
+ autoSend: true,
813
+ sentBy: config.processedBy,
814
+ });
815
+ }
816
+
817
+ return ok();
818
+ },
819
+ },
820
+
821
+ dashboard: {
822
+ get: async (): Promise<AccountsDashboardSummary> => {
823
+ const rows = await sql<DbRow[]>`
824
+ WITH latest_sync AS (
825
+ SELECT created_at, metadata
826
+ FROM logging.entries
827
+ WHERE source = 'auth:ipa:sync'
828
+ AND message = 'Sync complete'
829
+ ORDER BY created_at DESC
830
+ LIMIT 1
831
+ ),
832
+ recent_sync_runs AS (
833
+ SELECT message, metadata
834
+ FROM logging.entries
835
+ WHERE source = 'auth:ipa:sync'
836
+ AND message IN ('Sync complete', 'Sync step failed', 'Expired IPA demotion step failed')
837
+ ORDER BY created_at DESC
838
+ LIMIT 10
839
+ ),
840
+ recent_demotion_runs AS (
841
+ SELECT metadata
842
+ FROM logging.entries
843
+ WHERE source = 'auth:ipa:sync'
844
+ AND message = 'Expired IPA demotion complete'
845
+ ORDER BY created_at DESC
846
+ LIMIT 10
847
+ ),
848
+ recent_reminder_runs AS (
849
+ SELECT metadata
850
+ FROM logging.entries
851
+ WHERE source = 'auth:reminder:daily'
852
+ AND message = 'Reminder run complete'
853
+ ORDER BY created_at DESC
854
+ LIMIT 10
855
+ )
856
+ SELECT
857
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'ipa') AS ipa_accounts_total,
858
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local') AS local_accounts_total,
859
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'user') AS local_user_accounts_total,
860
+ (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'guest') AS local_guest_accounts_total,
861
+ (SELECT COUNT(*)::int FROM auth.groups) AS groups_total,
862
+ (SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'ipa') AS ipa_groups_total,
863
+ (SELECT COUNT(*)::int FROM auth.groups WHERE provider = 'local') AS local_groups_total,
864
+ (SELECT COUNT(*)::int FROM auth.account_requests WHERE status = 'pending') AS open_requests,
865
+ (
866
+ SELECT COUNT(*)::int
867
+ FROM auth.users
868
+ WHERE provider = 'ipa'
869
+ AND account_expires IS NOT NULL
870
+ AND account_expires > now()
871
+ AND account_expires <= now() + interval '30 days'
872
+ ) AS ipa_expiring_30d,
873
+ (
874
+ SELECT COUNT(*)::int
875
+ FROM auth.users
876
+ WHERE provider = 'local'
877
+ AND profile = 'guest'
878
+ AND account_expires IS NOT NULL
879
+ AND account_expires > now()
880
+ AND account_expires <= now() + interval '30 days'
881
+ ) AS local_guest_expiring_30d,
882
+ (
883
+ SELECT COUNT(*)::int
884
+ FROM auth.users
885
+ WHERE provider = 'local'
886
+ AND profile = 'user'
887
+ AND account_expires IS NOT NULL
888
+ AND account_expires > now()
889
+ AND account_expires <= now() + interval '30 days'
890
+ ) AS local_user_expiring_30d,
891
+ (
892
+ SELECT COUNT(*)::int
893
+ FROM auth.users
894
+ WHERE provider = 'local'
895
+ AND profile = 'guest'
896
+ AND account_expires IS NOT NULL
897
+ AND account_expires <= now()
898
+ ) AS overdue_local_guests,
899
+ (SELECT COUNT(*)::int FROM auth.account_lifecycle_reminders WHERE status = 'error') AS reminder_errors,
900
+ (SELECT COUNT(*)::int FROM auth.deleted_accounts WHERE deleted_at >= now() - interval '7 days') AS deleted_last_7d,
901
+ 10 AS run_health_window,
902
+ (SELECT COUNT(*)::int FROM recent_sync_runs) AS recent_sync_runs,
903
+ (
904
+ SELECT COUNT(*)::int
905
+ FROM recent_sync_runs
906
+ WHERE message <> 'Sync complete'
907
+ ) AS recent_sync_runs_with_failures,
908
+ (SELECT COUNT(*)::int FROM recent_demotion_runs) AS recent_demotion_runs,
909
+ (
910
+ SELECT COUNT(*)::int
911
+ FROM recent_demotion_runs
912
+ WHERE COALESCE((metadata->>'failed')::int, 0) > 0
913
+ ) AS recent_demotion_runs_with_failures,
914
+ (SELECT COUNT(*)::int FROM recent_reminder_runs) AS recent_reminder_runs,
915
+ (
916
+ SELECT COUNT(*)::int
917
+ FROM recent_reminder_runs
918
+ WHERE COALESCE((metadata->>'failed')::int, 0) > 0
919
+ ) AS recent_reminder_runs_with_failures,
920
+ (SELECT created_at FROM latest_sync) AS last_sync_created_at,
921
+ COALESCE((SELECT COALESCE((metadata->>'activeUsersSynced')::int, (metadata->>'users')::int) FROM latest_sync), 0) AS last_sync_users,
922
+ COALESCE((SELECT COALESCE((metadata->>'groupsSynced')::int, (metadata->>'groups')::int) FROM latest_sync), 0) AS last_sync_groups
923
+ `;
924
+ return mapSummary(rows[0] ?? {});
925
+ },
926
+ activity: async (): Promise<LogEntry[]> => {
927
+ const result = await logging.list(
928
+ { page: 1, perPage: 15, offset: 0 },
929
+ { sources: [...ACTIVITY_SOURCES] },
930
+ );
931
+ return result.entries;
932
+ },
933
+ },
934
+
935
+ lifecycle: {
936
+ deletedAccounts: { list: accountLifecycle.listDeletedAccounts },
937
+ reminders: { list: accountLifecycle.listReminderAudit },
938
+ },
939
+
940
+ jobs: {
941
+ runSync: async (): Promise<string> => lifecycleJobs.submitIpaSync(),
942
+ runIpaBackfill: async (): Promise<string> => lifecycleJobs.submitIpaBackfill(),
943
+ runLocalUserBackfill: async (): Promise<string> => lifecycleJobs.submitLocalUserBackfill(),
944
+ runGuestBackfill: async (): Promise<string> => lifecycleJobs.submitGuestBackfill(),
945
+ runReminders: async (): Promise<string> => lifecycleJobs.submitReminderRun(),
946
+ },
947
+
948
+ /**
949
+ * Obtain a privileged FreeIPA service session for operations performed on
950
+ * behalf of the system (e.g. a local group manager mutating an IPA group
951
+ * they don't personally own). Exposed through the facade so admin UIs
952
+ * never import `providers.*` directly.
953
+ */
954
+ getServiceIpaSession: async (): Promise<Result<string>> => {
955
+ if (!(await getFreeIpaConfig()).enabled) return fail(err.badInput("FreeIPA is disabled."));
956
+ try {
957
+ return ok(await providers.ipa.auth.getServiceSession());
958
+ } catch {
959
+ return fail(err.internal("Internal FreeIPA session unavailable."));
960
+ }
961
+ },
962
+ } as const;
963
+
964
+ export type AccountsAppService = typeof accountsAppService;
965
+ export type AccountsDashboardActivityEntry = LogEntry;
966
+ export { ACTIVITY_SOURCES };