@valentinkolb/cloud 0.4.0 → 0.5.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 (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -3,14 +3,18 @@ import type { User } from "../../contracts/shared";
3
3
  import { logger } from "../logging";
4
4
  import { notifications } from "../notifications";
5
5
  import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
6
+ import { audit } from "../audit";
6
7
  import { get as getSetting } from "../settings";
7
8
  import { renderTemplate } from "../settings/templates";
8
9
  import { session } from "../session";
9
10
  import { getConfiguredExpiryDays, parseIpaAccountTransitionPolicy } from "../account-model";
10
11
  import { getFreeIpaConfig } from "../freeipa-config";
12
+ import { getServiceIpaSession } from "../ipa/service-account";
13
+ import { buildEffectiveIpaGroupsByUid } from "../ipa/effective-groups";
14
+ import { providers } from "../providers";
11
15
  import { parsePgJsonRecord } from "../postgres";
12
16
  import { dates } from "../../shared";
13
- import { freeipa } from "../../server/services";
17
+ import { err, fail, freeipa, ok, type Result } from "../../server/services";
14
18
  import { writeDeletedAccountAudit } from "./audit";
15
19
  import { getIpaUrl } from "../ipa/guard";
16
20
 
@@ -55,6 +59,99 @@ const getGuestExpiresDays = async (): Promise<number> => {
55
59
  const getDeletedAccountsRetentionDays = async (): Promise<number> => settingInt("user.account.deleted_accounts_retention_days", 365);
56
60
  const getReminderHistoryRetentionDays = async (): Promise<number> => settingInt("user.account.reminder_history_retention_days", 365);
57
61
 
62
+ const readIpaList = (config: { response: Awaited<ReturnType<typeof freeipa.client.call>>; entity: string }): Result<Record<string, unknown>[]> => {
63
+ if (config.response.error) {
64
+ return fail(err.internal(`Could not verify FreeIPA ${config.entity} before extension.`));
65
+ }
66
+ const records = config.response.result?.result;
67
+ if (!Array.isArray(records)) {
68
+ return fail(err.internal(`Could not verify FreeIPA ${config.entity} before extension.`));
69
+ }
70
+ return ok(records as Record<string, unknown>[]);
71
+ };
72
+
73
+ const verifyCurrentIpaSyncScope = async (uid: string): Promise<Result<void>> => {
74
+ const config = await getFreeIpaConfig();
75
+ const serviceSession = await getServiceIpaSession();
76
+ if (!serviceSession.ok) {
77
+ return fail({
78
+ code: serviceSession.status === 400 ? "BAD_INPUT" : "INTERNAL",
79
+ message: serviceSession.error,
80
+ status: serviceSession.status,
81
+ });
82
+ }
83
+
84
+ const groupsRes = await freeipa.client.call({
85
+ url: config.url,
86
+ ipaSession: serviceSession.data,
87
+ method: "group_find",
88
+ args: [],
89
+ options: {
90
+ sizelimit: 0,
91
+ no_members: false,
92
+ all: true,
93
+ },
94
+ });
95
+ const groups = readIpaList({ response: groupsRes, entity: "groups" });
96
+ if (!groups.ok) return fail(groups.error);
97
+
98
+ const effectiveGroupsByUid = buildEffectiveIpaGroupsByUid(
99
+ groups.data.map((raw) => ({
100
+ cn: freeipa.util.str(raw.cn),
101
+ users: (raw.member_user as string[]) ?? [],
102
+ groups: (raw.member_group as string[]) ?? [],
103
+ })),
104
+ );
105
+ const effectiveGroups = effectiveGroupsByUid.get(uid) ?? [];
106
+ if (!config.groupsBaseSync.some((group) => effectiveGroups.includes(group))) {
107
+ return fail(err.forbidden("Your FreeIPA account is no longer in sync scope and cannot be extended."));
108
+ }
109
+
110
+ return ok();
111
+ };
112
+
113
+ const readFreshLocalIpaAccountExpiry = async (uid: string): Promise<Result<Date | null>> => {
114
+ const rows = await sql<DbRow[]>`
115
+ SELECT account_expires
116
+ FROM auth.users
117
+ WHERE uid = ${uid} AND provider = 'ipa'
118
+ `;
119
+ const row = rows[0];
120
+ if (!row) return fail(err.notFound("Your FreeIPA account was not found locally."));
121
+ return ok((row.account_expires as Date | null | undefined) ?? null);
122
+ };
123
+
124
+ const verifyIpaExtensionFreshness = async (uid: string): Promise<Result<{ accountExpires: Date }>> => {
125
+ const currentScope = await verifyCurrentIpaSyncScope(uid);
126
+ if (!currentScope.ok) return fail(currentScope.error);
127
+
128
+ const freshness = await providers.ipa.sync.user(uid).catch((error) => ({
129
+ status: "fetch_failed" as const,
130
+ error: error instanceof Error ? error.message : String(error),
131
+ }));
132
+
133
+ switch (freshness.status) {
134
+ case "synced": {
135
+ const accountExpires = await readFreshLocalIpaAccountExpiry(uid);
136
+ if (!accountExpires.ok) return accountExpires;
137
+ if (accountExpires.data === null) {
138
+ return fail(err.badInput("Accounts without an expiration date cannot be extended."));
139
+ }
140
+ return ok({ accountExpires: accountExpires.data });
141
+ }
142
+ case "expired":
143
+ return fail(err.badInput("Your FreeIPA account is expired and cannot be extended."));
144
+ case "out_of_scope":
145
+ return fail(err.forbidden("Your FreeIPA account is no longer in sync scope and cannot be extended."));
146
+ case "not_found_local":
147
+ return fail(err.notFound("Your FreeIPA account was not found locally."));
148
+ case "fetch_failed":
149
+ return fail(err.internal("Could not verify your FreeIPA account before extension."));
150
+ case "skipped_disabled":
151
+ return fail(err.badInput("FreeIPA is disabled."));
152
+ }
153
+ };
154
+
58
155
  const parseReminderDays = async (): Promise<number[]> => {
59
156
  const raw = await getSetting<number[]>("user.account.reminder_days");
60
157
  const parsed = Array.isArray(raw) ? raw.filter((entry) => Number.isInteger(entry) && entry > 0) : [];
@@ -675,20 +772,47 @@ export const accountLifecycle = {
675
772
 
676
773
  extendCurrentUserAccount: async (config: {
677
774
  user: User;
678
- ipaSession?: string | null;
679
- }): Promise<{ message: string; newExpiry?: string }> => {
775
+ }): Promise<Result<{ message: string; newExpiry?: string }>> => {
776
+ const auditParams = (result: Result<{ message: string; newExpiry?: string }>) => ({
777
+ action: "accounts.user.extend_account",
778
+ actor: {
779
+ userId: config.user.id,
780
+ uid: config.user.uid,
781
+ provider: config.user.provider,
782
+ roles: config.user.roles,
783
+ },
784
+ target: { type: "user", id: config.user.id, label: config.user.uid, provider: config.user.provider },
785
+ result,
786
+ });
787
+ const recordResult = (result: Result<{ message: string; newExpiry?: string }>) =>
788
+ audit.recordResult(auditParams(result));
789
+ const recordCompletedMutation = (result: Result<{ message: string; newExpiry?: string }>) =>
790
+ result.ok ? audit.recordResultAfterSideEffect(auditParams(result)) : audit.recordResult(auditParams(result));
791
+
792
+ if (config.user.accountExpires === null) {
793
+ return recordResult(fail(err.badInput("Accounts without an expiration date cannot be extended.")));
794
+ }
795
+
680
796
  if (config.user.provider === "ipa") {
681
797
  const freeIpaConfig = (await getFreeIpaConfig());
682
798
  if (!freeIpaConfig.enabled) {
683
- return { message: "FreeIPA is disabled." };
799
+ return recordResult(ok({ message: "FreeIPA is disabled." }));
684
800
  }
685
801
  const configuredDays = await getIpaExpiresDays();
686
802
  if (configuredDays <= 0) {
687
- return { message: "Automatic account expiry is disabled for IPA accounts." };
803
+ return recordResult(ok({ message: "Automatic account expiry is disabled for IPA accounts." }));
688
804
  }
689
805
 
690
- if (!config.ipaSession) {
691
- throw new Error("IPA session required to extend an IPA-backed account.");
806
+ const freshness = await verifyIpaExtensionFreshness(config.user.uid);
807
+ if (!freshness.ok) return recordResult(fail(freshness.error));
808
+
809
+ const serviceSession = await getServiceIpaSession();
810
+ if (!serviceSession.ok) {
811
+ return recordResult(fail({
812
+ code: serviceSession.status === 400 ? "BAD_INPUT" : "INTERNAL",
813
+ message: serviceSession.error,
814
+ status: serviceSession.status,
815
+ }));
692
816
  }
693
817
 
694
818
  const expiresAt = new Date(Date.now() + configuredDays * DAY_MS);
@@ -697,13 +821,13 @@ export const accountLifecycle = {
697
821
 
698
822
  const response = await freeipa.client.call({
699
823
  url: freeIpaConfig.url,
700
- ipaSession: config.ipaSession,
824
+ ipaSession: serviceSession.data,
701
825
  method: "user_mod",
702
826
  args: [config.user.uid],
703
827
  options: { krbprincipalexpiration: ipaExpiry },
704
828
  });
705
829
  if (response.error) {
706
- throw new Error(response.error.message || "Failed to extend IPA account.");
830
+ return recordResult(fail(err.badInput(response.error.message || "Failed to extend IPA account.")));
707
831
  }
708
832
 
709
833
  await sql`
@@ -717,10 +841,10 @@ export const accountLifecycle = {
717
841
  ON CONFLICT (user_id) DO UPDATE SET synced_at = EXCLUDED.synced_at
718
842
  `;
719
843
 
720
- return {
844
+ return recordCompletedMutation(ok({
721
845
  message: `Account extended until ${dates.formatDate(expiresAt)}.`,
722
846
  newExpiry: expiresAt.toISOString(),
723
- };
847
+ }));
724
848
  }
725
849
 
726
850
  if (config.user.provider === "local" && config.user.profile === "guest") {
@@ -731,7 +855,7 @@ export const accountLifecycle = {
731
855
  SET account_expires = NULL
732
856
  WHERE id = ${config.user.id}::uuid
733
857
  `;
734
- return { message: "Guest account expiry is disabled." };
858
+ return recordCompletedMutation(ok({ message: "Guest account expiry is disabled." }));
735
859
  }
736
860
 
737
861
  const expiresAt = new Date(Date.now() + guestDays * DAY_MS);
@@ -741,10 +865,10 @@ export const accountLifecycle = {
741
865
  WHERE id = ${config.user.id}::uuid
742
866
  `;
743
867
 
744
- return {
868
+ return recordCompletedMutation(ok({
745
869
  message: `Guest account extended until ${dates.formatDate(expiresAt)}.`,
746
870
  newExpiry: expiresAt.toISOString(),
747
- };
871
+ }));
748
872
  }
749
873
 
750
874
  if (config.user.provider === "local" && config.user.profile === "user") {
@@ -755,7 +879,7 @@ export const accountLifecycle = {
755
879
  SET account_expires = NULL
756
880
  WHERE id = ${config.user.id}::uuid
757
881
  `;
758
- return { message: "Local user account expiry is disabled." };
882
+ return recordCompletedMutation(ok({ message: "Local user account expiry is disabled." }));
759
883
  }
760
884
 
761
885
  const expiresAt = new Date(Date.now() + localUserDays * DAY_MS);
@@ -765,13 +889,13 @@ export const accountLifecycle = {
765
889
  WHERE id = ${config.user.id}::uuid
766
890
  `;
767
891
 
768
- return {
892
+ return recordCompletedMutation(ok({
769
893
  message: `Account extended until ${dates.formatDate(expiresAt)}.`,
770
894
  newExpiry: expiresAt.toISOString(),
771
- };
895
+ }));
772
896
  }
773
897
 
774
- return { message: "Your account does not support extension." };
898
+ return recordResult(ok({ message: "Your account does not support extension." }));
775
899
  },
776
900
 
777
901
  listDeletedAccounts: async (config: { page: number; perPage: number; reason?: string; search?: string }) => {