@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.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- 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 };
|