@nubitio/admin 0.11.3 → 1.0.0-rc.1

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/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { BrowserRouter, Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
1
+ import { BrowserRouter, Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
4
- import { Badge, Button, Card, EmptyState, FeatureGate as FeatureGate$1, IconButton, TextField, ThemeProvider, ThemeSwitcher, useFloatingPanel } from "@nubitio/ui";
3
+ import React, { Component, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
4
+ import { Badge, Button, Card, EmptyState, FeatureGate as FeatureGate$1, FormField, IconButton, Skeleton, TextField, ThemeProvider, ThemeSwitcher, useFloatingPanel } from "@nubitio/ui";
5
5
  import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
6
  import { CoreConfigProvider, CoreProvider, MercureProvider, useCoreHttpClient, useMercureSubscription } from "@nubitio/core";
7
- import { DevToolsProvider, SmartCrudRolesProvider, isDevEnvironment, useDevTools } from "@nubitio/crud";
7
+ import { DevToolsProvider, SessionPermissionsProvider, SmartCrudRolesProvider, isDevEnvironment, useDevTools } from "@nubitio/crud";
8
8
  import { HydraResourceSchemaProvider, HydraResourceStoreProvider, SchemaProvider } from "@nubitio/hydra";
9
9
  //#region packages/admin/FeatureHubLayout.tsx
10
10
  function normalizeIcon(icon) {
@@ -440,14 +440,14 @@ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThe
440
440
  //#endregion
441
441
  //#region packages/admin/auth/SessionContext.tsx
442
442
  const SessionContext = createContext(null);
443
- function joinApiPath$3(apiBaseUrl, path) {
443
+ function joinApiPath$7(apiBaseUrl, path) {
444
444
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
445
445
  }
446
446
  function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "auth/logout", children }) {
447
447
  const [session, setSession] = useState({ status: "loading" });
448
448
  const refresh = useCallback(async () => {
449
449
  try {
450
- const response = await fetch(joinApiPath$3(apiBaseUrl, mePath), { credentials: "include" });
450
+ const response = await fetch(joinApiPath$7(apiBaseUrl, mePath), { credentials: "include" });
451
451
  if (!response.ok) {
452
452
  setSession({ status: "anonymous" });
453
453
  return;
@@ -464,7 +464,7 @@ function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "au
464
464
  refresh();
465
465
  }, [refresh]);
466
466
  const logout = useCallback(async () => {
467
- await fetch(joinApiPath$3(apiBaseUrl, logoutPath), {
467
+ await fetch(joinApiPath$7(apiBaseUrl, logoutPath), {
468
468
  method: "POST",
469
469
  credentials: "include"
470
470
  });
@@ -830,12 +830,15 @@ function NotificationPanel({ title = "Notifications", emptyTitle = "You're all c
830
830
  }
831
831
  //#endregion
832
832
  //#region packages/admin/auth/LoginPage.tsx
833
- function joinApiPath$2(apiBaseUrl, path) {
833
+ function joinApiPath$6(apiBaseUrl, path) {
834
834
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
835
835
  }
836
- function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login", title = "Nubit Admin", hint, defaultUsername = "" }) {
836
+ const TOTP_REQUIRED = "second factor required.";
837
+ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login", title = "Nubit Admin", hint, defaultUsername = "", oidcProviders = [], forgotPasswordTo = "/forgot-password" }) {
837
838
  const [username, setUsername] = useState(defaultUsername);
838
839
  const [password, setPassword] = useState("");
840
+ const [totpCode, setTotpCode] = useState("");
841
+ const [needsTotp, setNeedsTotp] = useState(false);
839
842
  const [error, setError] = useState(null);
840
843
  const [busy, setBusy] = useState(false);
841
844
  const submit = async (event) => {
@@ -843,17 +846,25 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
843
846
  setBusy(true);
844
847
  setError(null);
845
848
  try {
846
- const response = await fetch(joinApiPath$2(apiBaseUrl, loginPath), {
849
+ const body = {
850
+ username,
851
+ password
852
+ };
853
+ if (totpCode !== "") body.totpCode = totpCode;
854
+ const response = await fetch(joinApiPath$6(apiBaseUrl, loginPath), {
847
855
  method: "POST",
848
856
  headers: { "Content-Type": "application/json" },
849
857
  credentials: "include",
850
- body: JSON.stringify({
851
- username,
852
- password
853
- })
858
+ body: JSON.stringify(body)
854
859
  });
855
860
  if (!response.ok) {
856
- setError((await response.json().catch(() => null))?.message ?? "Login failed");
861
+ const message = (await response.json().catch(() => null))?.message ?? "Login failed";
862
+ if (message.toLowerCase() === TOTP_REQUIRED) {
863
+ setNeedsTotp(true);
864
+ setError("Enter the code from your authenticator app.");
865
+ return;
866
+ }
867
+ setError(message);
857
868
  return;
858
869
  }
859
870
  onLoggedIn();
@@ -890,24 +901,41 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
890
901
  },
891
902
  children: hint
892
903
  }),
893
- /* @__PURE__ */ jsx(TextField, {
894
- placeholder: "Email",
895
- value: username,
896
- autoComplete: "username",
897
- onChange: (e) => setUsername(e.target.value)
904
+ /* @__PURE__ */ jsx(FormField, {
905
+ label: "Email",
906
+ children: /* @__PURE__ */ jsx(TextField, {
907
+ placeholder: "Email",
908
+ value: username,
909
+ autoComplete: "username",
910
+ onChange: (e) => setUsername(e.target.value)
911
+ })
898
912
  }),
899
- /* @__PURE__ */ jsx(TextField, {
900
- placeholder: "Password",
901
- type: "password",
902
- value: password,
903
- autoComplete: "current-password",
904
- onChange: (e) => setPassword(e.target.value)
913
+ /* @__PURE__ */ jsx(FormField, {
914
+ label: "Password",
915
+ children: /* @__PURE__ */ jsx(TextField, {
916
+ placeholder: "Password",
917
+ type: "password",
918
+ value: password,
919
+ autoComplete: "current-password",
920
+ onChange: (e) => setPassword(e.target.value)
921
+ })
922
+ }),
923
+ needsTotp && /* @__PURE__ */ jsx(FormField, {
924
+ label: "Authenticator code",
925
+ children: /* @__PURE__ */ jsx(TextField, {
926
+ placeholder: "123456",
927
+ value: totpCode,
928
+ autoComplete: "one-time-code",
929
+ inputMode: "numeric",
930
+ onChange: (e) => setTotpCode(e.target.value)
931
+ })
905
932
  }),
906
933
  error && /* @__PURE__ */ jsx("p", {
907
934
  style: {
908
935
  margin: 0,
909
936
  color: "var(--error-color, #dc2626)"
910
937
  },
938
+ role: "alert",
911
939
  children: error
912
940
  }),
913
941
  /* @__PURE__ */ jsx(Button, {
@@ -915,12 +943,587 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
915
943
  type: "submit",
916
944
  disabled: busy,
917
945
  children: busy ? "Signing in…" : "Sign in"
946
+ }),
947
+ /* @__PURE__ */ jsx(Link, {
948
+ to: forgotPasswordTo,
949
+ style: { fontSize: "0.875rem" },
950
+ children: "Forgot password?"
951
+ }),
952
+ oidcProviders.map((provider) => /* @__PURE__ */ jsx(Button, {
953
+ variant: "secondary",
954
+ type: "button",
955
+ onClick: () => {
956
+ window.location.assign(joinApiPath$6(apiBaseUrl, `auth/oidc/${provider.id}/redirect`));
957
+ },
958
+ children: provider.label
959
+ }, provider.id))
960
+ ]
961
+ }) })
962
+ });
963
+ }
964
+ //#endregion
965
+ //#region packages/admin/auth/ForgotPasswordPage.tsx
966
+ function joinApiPath$5(apiBaseUrl, path) {
967
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
968
+ }
969
+ function ForgotPasswordPage({ apiBaseUrl = "/api/" }) {
970
+ const [username, setUsername] = useState("");
971
+ const [sent, setSent] = useState(false);
972
+ const [busy, setBusy] = useState(false);
973
+ const submit = async (event) => {
974
+ event.preventDefault();
975
+ setBusy(true);
976
+ try {
977
+ await fetch(joinApiPath$5(apiBaseUrl, "auth/password/forgot"), {
978
+ method: "POST",
979
+ headers: { "Content-Type": "application/json" },
980
+ credentials: "include",
981
+ body: JSON.stringify({ username })
982
+ });
983
+ setSent(true);
984
+ } finally {
985
+ setBusy(false);
986
+ }
987
+ };
988
+ return /* @__PURE__ */ jsx("div", {
989
+ style: {
990
+ display: "grid",
991
+ placeItems: "center",
992
+ minHeight: "100vh"
993
+ },
994
+ children: /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs("form", {
995
+ onSubmit: submit,
996
+ style: {
997
+ display: "flex",
998
+ flexDirection: "column",
999
+ gap: 12,
1000
+ width: 320,
1001
+ padding: 8
1002
+ },
1003
+ children: [
1004
+ /* @__PURE__ */ jsx("h2", {
1005
+ style: { margin: 0 },
1006
+ children: "Reset password"
1007
+ }),
1008
+ sent ? /* @__PURE__ */ jsx("p", {
1009
+ style: {
1010
+ margin: 0,
1011
+ color: "var(--text-secondary)"
1012
+ },
1013
+ children: "If that address has an account, a reset link is on its way."
1014
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(FormField, {
1015
+ label: "Email",
1016
+ children: /* @__PURE__ */ jsx(TextField, {
1017
+ value: username,
1018
+ autoComplete: "username",
1019
+ onChange: (e) => setUsername(e.target.value)
1020
+ })
1021
+ }), /* @__PURE__ */ jsx(Button, {
1022
+ variant: "primary",
1023
+ type: "submit",
1024
+ disabled: busy || username === "",
1025
+ children: busy ? "Sending…" : "Send reset link"
1026
+ })] }),
1027
+ /* @__PURE__ */ jsx(Link, {
1028
+ to: "/",
1029
+ style: { fontSize: "0.875rem" },
1030
+ children: "Back to sign in"
918
1031
  })
919
1032
  ]
920
1033
  }) })
921
1034
  });
922
1035
  }
923
1036
  //#endregion
1037
+ //#region packages/admin/auth/ResetPasswordPage.tsx
1038
+ function joinApiPath$4(apiBaseUrl, path) {
1039
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
1040
+ }
1041
+ function ResetPasswordPage({ apiBaseUrl = "/api/" }) {
1042
+ const [params] = useSearchParams();
1043
+ const token = params.get("token") ?? "";
1044
+ const [password, setPassword] = useState("");
1045
+ const [error, setError] = useState(null);
1046
+ const [done, setDone] = useState(false);
1047
+ const [busy, setBusy] = useState(false);
1048
+ const submit = async (event) => {
1049
+ event.preventDefault();
1050
+ setBusy(true);
1051
+ setError(null);
1052
+ try {
1053
+ const response = await fetch(joinApiPath$4(apiBaseUrl, "auth/password/reset"), {
1054
+ method: "POST",
1055
+ headers: { "Content-Type": "application/json" },
1056
+ credentials: "include",
1057
+ body: JSON.stringify({
1058
+ token,
1059
+ password
1060
+ })
1061
+ });
1062
+ if (!response.ok) {
1063
+ setError((await response.json().catch(() => null))?.message ?? "Reset failed");
1064
+ return;
1065
+ }
1066
+ setDone(true);
1067
+ } catch {
1068
+ setError("Network error");
1069
+ } finally {
1070
+ setBusy(false);
1071
+ }
1072
+ };
1073
+ return /* @__PURE__ */ jsx("div", {
1074
+ style: {
1075
+ display: "grid",
1076
+ placeItems: "center",
1077
+ minHeight: "100vh"
1078
+ },
1079
+ children: /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs("form", {
1080
+ onSubmit: submit,
1081
+ style: {
1082
+ display: "flex",
1083
+ flexDirection: "column",
1084
+ gap: 12,
1085
+ width: 320,
1086
+ padding: 8
1087
+ },
1088
+ children: [/* @__PURE__ */ jsx("h2", {
1089
+ style: { margin: 0 },
1090
+ children: "Choose a new password"
1091
+ }), done ? /* @__PURE__ */ jsxs("p", {
1092
+ style: { margin: 0 },
1093
+ children: [
1094
+ "Password updated.",
1095
+ " ",
1096
+ /* @__PURE__ */ jsx(Link, {
1097
+ to: "/",
1098
+ style: { fontSize: "0.875rem" },
1099
+ children: "Sign in"
1100
+ })
1101
+ ]
1102
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1103
+ /* @__PURE__ */ jsx(FormField, {
1104
+ label: "New password",
1105
+ children: /* @__PURE__ */ jsx(TextField, {
1106
+ type: "password",
1107
+ value: password,
1108
+ autoComplete: "new-password",
1109
+ minLength: 8,
1110
+ onChange: (e) => setPassword(e.target.value)
1111
+ })
1112
+ }),
1113
+ error && /* @__PURE__ */ jsx("p", {
1114
+ style: {
1115
+ margin: 0,
1116
+ color: "var(--error-color, #dc2626)"
1117
+ },
1118
+ role: "alert",
1119
+ children: error
1120
+ }),
1121
+ /* @__PURE__ */ jsx(Button, {
1122
+ variant: "primary",
1123
+ type: "submit",
1124
+ disabled: busy || token === "" || password.length < 8,
1125
+ children: busy ? "Saving…" : "Update password"
1126
+ })
1127
+ ] })]
1128
+ }) })
1129
+ });
1130
+ }
1131
+ //#endregion
1132
+ //#region packages/admin/auth/AcceptInvitationPage.tsx
1133
+ function joinApiPath$3(apiBaseUrl, path) {
1134
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
1135
+ }
1136
+ function AcceptInvitationPage({ apiBaseUrl = "/api/" }) {
1137
+ const { token = "" } = useParams();
1138
+ const [email, setEmail] = useState(null);
1139
+ const [password, setPassword] = useState("");
1140
+ const [error, setError] = useState(null);
1141
+ const [done, setDone] = useState(false);
1142
+ const [busy, setBusy] = useState(false);
1143
+ useEffect(() => {
1144
+ let cancelled = false;
1145
+ fetch(joinApiPath$3(apiBaseUrl, `invitations/${token}`), { credentials: "include" }).then(async (response) => {
1146
+ if (cancelled) return;
1147
+ if (!response.ok) {
1148
+ setError("This invitation is no longer valid.");
1149
+ return;
1150
+ }
1151
+ setEmail((await response.json()).email ?? null);
1152
+ }).catch(() => {
1153
+ if (!cancelled) setError("Network error");
1154
+ });
1155
+ return () => {
1156
+ cancelled = true;
1157
+ };
1158
+ }, [apiBaseUrl, token]);
1159
+ const submit = async (event) => {
1160
+ event.preventDefault();
1161
+ setBusy(true);
1162
+ setError(null);
1163
+ try {
1164
+ const response = await fetch(joinApiPath$3(apiBaseUrl, `invitations/${token}/accept`), {
1165
+ method: "POST",
1166
+ headers: { "Content-Type": "application/json" },
1167
+ credentials: "include",
1168
+ body: JSON.stringify({ password })
1169
+ });
1170
+ if (!response.ok) {
1171
+ setError((await response.json().catch(() => null))?.message ?? "Could not accept the invitation");
1172
+ return;
1173
+ }
1174
+ setDone(true);
1175
+ } catch {
1176
+ setError("Network error");
1177
+ } finally {
1178
+ setBusy(false);
1179
+ }
1180
+ };
1181
+ return /* @__PURE__ */ jsx("div", {
1182
+ style: {
1183
+ display: "grid",
1184
+ placeItems: "center",
1185
+ minHeight: "100vh"
1186
+ },
1187
+ children: /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs("form", {
1188
+ onSubmit: submit,
1189
+ style: {
1190
+ display: "flex",
1191
+ flexDirection: "column",
1192
+ gap: 12,
1193
+ width: 320,
1194
+ padding: 8
1195
+ },
1196
+ children: [/* @__PURE__ */ jsx("h2", {
1197
+ style: { margin: 0 },
1198
+ children: "Join"
1199
+ }), done ? /* @__PURE__ */ jsxs("p", {
1200
+ style: { margin: 0 },
1201
+ children: [
1202
+ "Account created.",
1203
+ " ",
1204
+ /* @__PURE__ */ jsx(Link, {
1205
+ to: "/",
1206
+ style: { fontSize: "0.875rem" },
1207
+ children: "Sign in"
1208
+ })
1209
+ ]
1210
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1211
+ email && /* @__PURE__ */ jsx("p", {
1212
+ style: {
1213
+ margin: 0,
1214
+ color: "var(--text-secondary)"
1215
+ },
1216
+ children: email
1217
+ }),
1218
+ /* @__PURE__ */ jsx(FormField, {
1219
+ label: "Password",
1220
+ children: /* @__PURE__ */ jsx(TextField, {
1221
+ type: "password",
1222
+ value: password,
1223
+ autoComplete: "new-password",
1224
+ minLength: 8,
1225
+ onChange: (e) => setPassword(e.target.value)
1226
+ })
1227
+ }),
1228
+ error && /* @__PURE__ */ jsx("p", {
1229
+ style: {
1230
+ margin: 0,
1231
+ color: "var(--error-color, #dc2626)"
1232
+ },
1233
+ role: "alert",
1234
+ children: error
1235
+ }),
1236
+ /* @__PURE__ */ jsx(Button, {
1237
+ variant: "primary",
1238
+ type: "submit",
1239
+ disabled: busy || password.length < 8,
1240
+ children: busy ? "Creating…" : "Create account"
1241
+ })
1242
+ ] })]
1243
+ }) })
1244
+ });
1245
+ }
1246
+ //#endregion
1247
+ //#region packages/admin/auth/AccountPage.tsx
1248
+ function joinApiPath$2(apiBaseUrl, path) {
1249
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
1250
+ }
1251
+ function AccountPage({ apiBaseUrl = "/api/" }) {
1252
+ const { username, refresh } = useSession();
1253
+ const [currentPassword, setCurrentPassword] = useState("");
1254
+ const [newPassword, setNewPassword] = useState("");
1255
+ const [passwordMessage, setPasswordMessage] = useState(null);
1256
+ const [totp, setTotp] = useState(null);
1257
+ const [totpCode, setTotpCode] = useState("");
1258
+ const [totpSecret, setTotpSecret] = useState(null);
1259
+ const [totpMessage, setTotpMessage] = useState(null);
1260
+ const [sessions, setSessions] = useState([]);
1261
+ const load = useCallback(async (signal) => {
1262
+ const [totpResponse, sessionResponse] = await Promise.all([fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1263
+ credentials: "include",
1264
+ signal
1265
+ }), fetch(joinApiPath$2(apiBaseUrl, "auth/sessions"), {
1266
+ credentials: "include",
1267
+ signal
1268
+ })]);
1269
+ if (signal?.aborted) return;
1270
+ if (totpResponse.ok) setTotp(await totpResponse.json());
1271
+ if (sessionResponse.ok) setSessions((await sessionResponse.json()).sessions ?? []);
1272
+ }, [apiBaseUrl]);
1273
+ useEffect(() => {
1274
+ const controller = new AbortController();
1275
+ load(controller.signal).catch((error) => {
1276
+ if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
1277
+ });
1278
+ return () => controller.abort();
1279
+ }, [load]);
1280
+ const changePassword = async (event) => {
1281
+ event.preventDefault();
1282
+ setPasswordMessage(null);
1283
+ const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/change-password"), {
1284
+ method: "POST",
1285
+ headers: { "Content-Type": "application/json" },
1286
+ credentials: "include",
1287
+ body: JSON.stringify({
1288
+ currentPassword,
1289
+ newPassword
1290
+ })
1291
+ });
1292
+ if (!response.ok) {
1293
+ setPasswordMessage((await response.json().catch(() => null))?.message ?? "Could not change password");
1294
+ return;
1295
+ }
1296
+ setCurrentPassword("");
1297
+ setNewPassword("");
1298
+ setPasswordMessage("Password updated.");
1299
+ await refresh();
1300
+ };
1301
+ const beginTotp = async () => {
1302
+ setTotpMessage(null);
1303
+ const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1304
+ method: "POST",
1305
+ credentials: "include"
1306
+ });
1307
+ if (!response.ok) {
1308
+ setTotpMessage((await response.json().catch(() => null))?.message ?? "Could not start enrolment");
1309
+ return;
1310
+ }
1311
+ const payload = await response.json();
1312
+ setTotpSecret(payload.uri ?? payload.secret ?? null);
1313
+ await load();
1314
+ };
1315
+ const confirmTotp = async (event) => {
1316
+ event.preventDefault();
1317
+ const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp/confirm"), {
1318
+ method: "POST",
1319
+ headers: { "Content-Type": "application/json" },
1320
+ credentials: "include",
1321
+ body: JSON.stringify({ code: totpCode })
1322
+ });
1323
+ if (!response.ok) {
1324
+ setTotpMessage((await response.json().catch(() => null))?.message ?? "That code is not valid");
1325
+ return;
1326
+ }
1327
+ setTotpCode("");
1328
+ setTotpSecret(null);
1329
+ setTotpMessage("Authenticator enrolled.");
1330
+ await load();
1331
+ };
1332
+ const disableTotp = async (event) => {
1333
+ event.preventDefault();
1334
+ const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1335
+ method: "DELETE",
1336
+ headers: { "Content-Type": "application/json" },
1337
+ credentials: "include",
1338
+ body: JSON.stringify({ code: totpCode })
1339
+ });
1340
+ if (!response.ok) {
1341
+ setTotpMessage((await response.json().catch(() => null))?.message ?? "Could not disable the second factor");
1342
+ return;
1343
+ }
1344
+ setTotpCode("");
1345
+ setTotpMessage("Second factor removed.");
1346
+ await load();
1347
+ };
1348
+ const revokeSession = async (id) => {
1349
+ await fetch(joinApiPath$2(apiBaseUrl, `auth/sessions/${id}`), {
1350
+ method: "DELETE",
1351
+ credentials: "include"
1352
+ });
1353
+ await load();
1354
+ };
1355
+ return /* @__PURE__ */ jsxs("div", {
1356
+ style: {
1357
+ display: "grid",
1358
+ gap: 24,
1359
+ maxWidth: 480,
1360
+ padding: 24
1361
+ },
1362
+ children: [
1363
+ /* @__PURE__ */ jsx("h1", {
1364
+ style: { margin: 0 },
1365
+ children: "Account"
1366
+ }),
1367
+ /* @__PURE__ */ jsx("p", {
1368
+ style: {
1369
+ margin: 0,
1370
+ color: "var(--text-secondary)"
1371
+ },
1372
+ children: username
1373
+ }),
1374
+ /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs("form", {
1375
+ onSubmit: changePassword,
1376
+ style: {
1377
+ display: "flex",
1378
+ flexDirection: "column",
1379
+ gap: 12,
1380
+ padding: 8
1381
+ },
1382
+ children: [
1383
+ /* @__PURE__ */ jsx("h2", {
1384
+ style: {
1385
+ margin: 0,
1386
+ fontSize: "1rem"
1387
+ },
1388
+ children: "Password"
1389
+ }),
1390
+ /* @__PURE__ */ jsx(FormField, {
1391
+ label: "Current password",
1392
+ children: /* @__PURE__ */ jsx(TextField, {
1393
+ type: "password",
1394
+ value: currentPassword,
1395
+ autoComplete: "current-password",
1396
+ onChange: (e) => setCurrentPassword(e.target.value)
1397
+ })
1398
+ }),
1399
+ /* @__PURE__ */ jsx(FormField, {
1400
+ label: "New password",
1401
+ children: /* @__PURE__ */ jsx(TextField, {
1402
+ type: "password",
1403
+ value: newPassword,
1404
+ autoComplete: "new-password",
1405
+ minLength: 8,
1406
+ onChange: (e) => setNewPassword(e.target.value)
1407
+ })
1408
+ }),
1409
+ passwordMessage && /* @__PURE__ */ jsx("p", {
1410
+ style: { margin: 0 },
1411
+ children: passwordMessage
1412
+ }),
1413
+ /* @__PURE__ */ jsx(Button, {
1414
+ variant: "primary",
1415
+ type: "submit",
1416
+ disabled: newPassword.length < 8,
1417
+ children: "Update password"
1418
+ })
1419
+ ]
1420
+ }) }),
1421
+ /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs("div", {
1422
+ style: {
1423
+ display: "flex",
1424
+ flexDirection: "column",
1425
+ gap: 12,
1426
+ padding: 8
1427
+ },
1428
+ children: [
1429
+ /* @__PURE__ */ jsx("h2", {
1430
+ style: {
1431
+ margin: 0,
1432
+ fontSize: "1rem"
1433
+ },
1434
+ children: "Authenticator"
1435
+ }),
1436
+ /* @__PURE__ */ jsx("p", {
1437
+ style: {
1438
+ margin: 0,
1439
+ color: "var(--text-secondary)"
1440
+ },
1441
+ children: totp?.enrolled ? "A second factor is enrolled on this account." : "No second factor yet."
1442
+ }),
1443
+ totpSecret && /* @__PURE__ */ jsx("p", {
1444
+ style: {
1445
+ margin: 0,
1446
+ wordBreak: "break-all",
1447
+ fontSize: "0.75rem"
1448
+ },
1449
+ children: totpSecret
1450
+ }),
1451
+ /* @__PURE__ */ jsxs("form", {
1452
+ onSubmit: totp?.enrolled ? disableTotp : totpSecret ? confirmTotp : beginTotp,
1453
+ style: {
1454
+ display: "flex",
1455
+ flexDirection: "column",
1456
+ gap: 12
1457
+ },
1458
+ children: [
1459
+ (totp?.enrolled || totpSecret) && /* @__PURE__ */ jsx(FormField, {
1460
+ label: "Authenticator code",
1461
+ children: /* @__PURE__ */ jsx(TextField, {
1462
+ value: totpCode,
1463
+ autoComplete: "one-time-code",
1464
+ inputMode: "numeric",
1465
+ onChange: (e) => setTotpCode(e.target.value)
1466
+ })
1467
+ }),
1468
+ totpMessage && /* @__PURE__ */ jsx("p", {
1469
+ style: { margin: 0 },
1470
+ children: totpMessage
1471
+ }),
1472
+ /* @__PURE__ */ jsx(Button, {
1473
+ variant: "secondary",
1474
+ type: "submit",
1475
+ children: totp?.enrolled ? "Disable" : totpSecret ? "Confirm" : "Enrol"
1476
+ })
1477
+ ]
1478
+ })
1479
+ ]
1480
+ }) }),
1481
+ /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs("div", {
1482
+ style: {
1483
+ display: "flex",
1484
+ flexDirection: "column",
1485
+ gap: 12,
1486
+ padding: 8
1487
+ },
1488
+ children: [
1489
+ /* @__PURE__ */ jsx("h2", {
1490
+ style: {
1491
+ margin: 0,
1492
+ fontSize: "1rem"
1493
+ },
1494
+ children: "Sessions"
1495
+ }),
1496
+ sessions.length === 0 && /* @__PURE__ */ jsx("p", {
1497
+ style: {
1498
+ margin: 0,
1499
+ color: "var(--text-secondary)"
1500
+ },
1501
+ children: "No other sessions."
1502
+ }),
1503
+ sessions.map((session) => /* @__PURE__ */ jsxs("div", {
1504
+ style: {
1505
+ display: "flex",
1506
+ justifyContent: "space-between",
1507
+ gap: 12,
1508
+ alignItems: "center"
1509
+ },
1510
+ children: [/* @__PURE__ */ jsx("span", {
1511
+ style: { fontSize: "0.875rem" },
1512
+ children: session.lastUsedAt ?? session.createdAt ?? `Session ${session.id}`
1513
+ }), /* @__PURE__ */ jsx(Button, {
1514
+ variant: "secondary",
1515
+ size: "sm",
1516
+ type: "button",
1517
+ onClick: () => void revokeSession(session.id),
1518
+ children: "Revoke"
1519
+ })]
1520
+ }, session.id))
1521
+ ]
1522
+ }) })
1523
+ ]
1524
+ });
1525
+ }
1526
+ //#endregion
924
1527
  //#region packages/admin/auth/RegisterPage.tsx
925
1528
  function joinApiPath$1(apiBaseUrl, path) {
926
1529
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
@@ -1288,6 +1891,54 @@ function NubitDevToolsPanel() {
1288
1891
  });
1289
1892
  }
1290
1893
  //#endregion
1894
+ //#region packages/admin/app/ErrorBoundary.tsx
1895
+ /**
1896
+ * Keeps a thrown field renderer from taking the whole admin down with it.
1897
+ */
1898
+ var ErrorBoundary = class extends Component {
1899
+ state = { error: null };
1900
+ static getDerivedStateFromError(error) {
1901
+ return { error };
1902
+ }
1903
+ componentDidCatch(error, info) {
1904
+ console.error("Nubit admin render error", error, info.componentStack);
1905
+ }
1906
+ render() {
1907
+ if (!this.state.error) return this.props.children;
1908
+ return /* @__PURE__ */ jsxs("div", {
1909
+ role: "alert",
1910
+ style: {
1911
+ padding: 24,
1912
+ maxWidth: 480
1913
+ },
1914
+ children: [
1915
+ /* @__PURE__ */ jsx("h1", {
1916
+ style: {
1917
+ margin: "0 0 8px",
1918
+ fontSize: "1.25rem"
1919
+ },
1920
+ children: "Something went wrong"
1921
+ }),
1922
+ /* @__PURE__ */ jsx("p", {
1923
+ style: {
1924
+ margin: "0 0 16px",
1925
+ color: "var(--text-secondary)"
1926
+ },
1927
+ children: "Reload the page. If it happens again, the last action is in the browser console."
1928
+ }),
1929
+ /* @__PURE__ */ jsx(Button, {
1930
+ variant: "primary",
1931
+ onClick: () => {
1932
+ this.setState({ error: null });
1933
+ window.location.reload();
1934
+ },
1935
+ children: "Reload"
1936
+ })
1937
+ ]
1938
+ });
1939
+ }
1940
+ };
1941
+ //#endregion
1291
1942
  //#region packages/admin/app/filterMenuByRoles.ts
1292
1943
  function hasAnyRole(required, roles) {
1293
1944
  if (required === void 0) return true;
@@ -1336,20 +1987,28 @@ function defaultUserMenu({ username, close, logout }) {
1336
1987
  gap: 8,
1337
1988
  minWidth: 180
1338
1989
  },
1339
- children: [/* @__PURE__ */ jsx("span", {
1340
- style: {
1341
- color: "var(--text-secondary)",
1342
- fontSize: "0.875rem"
1343
- },
1344
- children: username ?? "User"
1345
- }), /* @__PURE__ */ jsx("button", {
1346
- type: "button",
1347
- onClick: () => {
1348
- close();
1349
- logout();
1350
- },
1351
- children: "Sign out"
1352
- })]
1990
+ children: [
1991
+ /* @__PURE__ */ jsx("span", {
1992
+ style: {
1993
+ color: "var(--text-secondary)",
1994
+ fontSize: "0.875rem"
1995
+ },
1996
+ children: username ?? "User"
1997
+ }),
1998
+ /* @__PURE__ */ jsx(Link, {
1999
+ to: "/account",
2000
+ onClick: close,
2001
+ children: "Account"
2002
+ }),
2003
+ /* @__PURE__ */ jsx("button", {
2004
+ type: "button",
2005
+ onClick: () => {
2006
+ close();
2007
+ logout();
2008
+ },
2009
+ children: "Sign out"
2010
+ })
2011
+ ]
1353
2012
  });
1354
2013
  }
1355
2014
  function buildMenuContext(session) {
@@ -1386,7 +2045,25 @@ function NubitAuthenticatedApp({ config }) {
1386
2045
  const renderThemeSwitcher = config.renderThemeSwitcher ?? (() => /* @__PURE__ */ jsx(ThemeSwitcher, {}));
1387
2046
  const renderUserMenu = config.renderUserMenu ?? defaultUserMenu;
1388
2047
  const Wrapper = config.Wrapper ?? React.Fragment;
1389
- if (session.session.status === "loading") return null;
2048
+ const profile = session.session.status === "authenticated" ? session.session.profile : void 0;
2049
+ if (session.session.status === "loading") return /* @__PURE__ */ jsxs("div", {
2050
+ style: { padding: 24 },
2051
+ "aria-busy": "true",
2052
+ "aria-label": "Loading session",
2053
+ children: [
2054
+ /* @__PURE__ */ jsx(Skeleton, {
2055
+ variant: "rect",
2056
+ height: 32,
2057
+ width: "33%"
2058
+ }),
2059
+ /* @__PURE__ */ jsx("div", { style: { height: 16 } }),
2060
+ /* @__PURE__ */ jsx(Skeleton, {
2061
+ variant: "rect",
2062
+ height: 256
2063
+ })
2064
+ ]
2065
+ });
2066
+ const hasAccountRoute = config.routes.some((route) => route.path === "/account");
1390
2067
  const shell = /* @__PURE__ */ jsx(AdminShell, {
1391
2068
  title: config.title,
1392
2069
  menuItems,
@@ -1397,54 +2074,86 @@ function NubitAuthenticatedApp({ config }) {
1397
2074
  ...menuContext,
1398
2075
  close
1399
2076
  }),
1400
- children: /* @__PURE__ */ jsxs(Routes, { children: [/* @__PURE__ */ jsx(Route, {
1401
- path: "/",
1402
- element: /* @__PURE__ */ jsx(Navigate, {
1403
- to: homePath,
1404
- replace: true
2077
+ children: /* @__PURE__ */ jsxs(Routes, { children: [
2078
+ /* @__PURE__ */ jsx(Route, {
2079
+ path: "/",
2080
+ element: /* @__PURE__ */ jsx(Navigate, {
2081
+ to: homePath,
2082
+ replace: true
2083
+ })
2084
+ }),
2085
+ config.routes.map((route) => /* @__PURE__ */ jsx(Route, {
2086
+ path: route.path,
2087
+ element: route.element
2088
+ }, route.path)),
2089
+ !hasAccountRoute && /* @__PURE__ */ jsx(Route, {
2090
+ path: "/account",
2091
+ element: /* @__PURE__ */ jsx(AccountPage, { apiBaseUrl })
1405
2092
  })
1406
- }), config.routes.map((route) => /* @__PURE__ */ jsx(Route, {
1407
- path: route.path,
1408
- element: route.element
1409
- }, route.path))] })
2093
+ ] })
1410
2094
  });
1411
2095
  const devToolsEnabled = config.devTools ?? isDevEnvironment();
1412
- const authenticated = /* @__PURE__ */ jsx(DevToolsProvider, {
2096
+ const authenticated = /* @__PURE__ */ jsx(ErrorBoundary, { children: /* @__PURE__ */ jsx(DevToolsProvider, {
1413
2097
  enabled: devToolsEnabled,
1414
2098
  children: /* @__PURE__ */ jsx(CoreProvider, {
1415
2099
  http: {
1416
2100
  baseUrl: apiBaseUrl,
1417
2101
  refreshPath: "auth/refresh",
1418
- loginPath: "auth/login"
2102
+ loginPath: "auth/login",
2103
+ onUnauthorized: () => {
2104
+ session.logout();
2105
+ }
1419
2106
  },
1420
2107
  runtime,
1421
2108
  children: /* @__PURE__ */ jsx(CoreConfigProvider, {
1422
2109
  apiBaseUrl,
1423
2110
  locale: config.locale ?? "en",
1424
- timezone: config.timezone ?? "UTC",
2111
+ timezone: profile?.timeZone ?? config.timezone ?? "UTC",
1425
2112
  currency: config.currency ?? "USD",
1426
- children: /* @__PURE__ */ jsxs(SmartCrudRolesProvider, {
2113
+ children: /* @__PURE__ */ jsx(SmartCrudRolesProvider, {
1427
2114
  roles: session.roles,
1428
- children: [
1429
- /* @__PURE__ */ jsx(BrowserRouter, { children: /* @__PURE__ */ jsx(Wrapper, { children: config.hydra === false ? shell : /* @__PURE__ */ jsx(MercureProvider, { children: /* @__PURE__ */ jsx(SchemaProvider, { children: /* @__PURE__ */ jsx(HydraResourceSchemaProvider, { children: /* @__PURE__ */ jsx(HydraResourceStoreProvider, { children: shell }) }) }) }) }) }),
1430
- /* @__PURE__ */ jsx(ToastHost, {
1431
- toasts,
1432
- onDismiss: dismiss
1433
- }),
1434
- devToolsEnabled && /* @__PURE__ */ jsx(NubitDevToolsPanel, {})
1435
- ]
2115
+ children: /* @__PURE__ */ jsxs(SessionPermissionsProvider, {
2116
+ permissions: profile?.permissions,
2117
+ limits: profile?.limits,
2118
+ children: [
2119
+ /* @__PURE__ */ jsx(BrowserRouter, { children: /* @__PURE__ */ jsx(Wrapper, { children: config.hydra === false ? shell : /* @__PURE__ */ jsx(MercureProvider, { children: /* @__PURE__ */ jsx(SchemaProvider, { children: /* @__PURE__ */ jsx(HydraResourceSchemaProvider, { children: /* @__PURE__ */ jsx(HydraResourceStoreProvider, { children: shell }) }) }) }) }) }),
2120
+ /* @__PURE__ */ jsx(ToastHost, {
2121
+ toasts,
2122
+ onDismiss: dismiss
2123
+ }),
2124
+ devToolsEnabled && /* @__PURE__ */ jsx(NubitDevToolsPanel, {})
2125
+ ]
2126
+ })
1436
2127
  })
1437
2128
  })
1438
2129
  })
1439
- });
1440
- if (session.session.status === "authenticated") return authenticated;
1441
- return /* @__PURE__ */ jsx(BrowserRouter, { children: /* @__PURE__ */ jsx(LoginPage, {
1442
- apiBaseUrl,
1443
- title: config.login?.title ?? config.title,
1444
- hint: config.login?.hint,
1445
- defaultUsername: config.login?.defaultUsername,
1446
- onLoggedIn: () => void session.refresh()
1447
2130
  }) });
2131
+ if (session.session.status === "authenticated") return authenticated;
2132
+ return /* @__PURE__ */ jsx(BrowserRouter, { children: /* @__PURE__ */ jsxs(Routes, { children: [
2133
+ /* @__PURE__ */ jsx(Route, {
2134
+ path: "/forgot-password",
2135
+ element: /* @__PURE__ */ jsx(ForgotPasswordPage, { apiBaseUrl })
2136
+ }),
2137
+ /* @__PURE__ */ jsx(Route, {
2138
+ path: "/reset-password",
2139
+ element: /* @__PURE__ */ jsx(ResetPasswordPage, { apiBaseUrl })
2140
+ }),
2141
+ /* @__PURE__ */ jsx(Route, {
2142
+ path: "/invitations/:token/accept",
2143
+ element: /* @__PURE__ */ jsx(AcceptInvitationPage, { apiBaseUrl })
2144
+ }),
2145
+ /* @__PURE__ */ jsx(Route, {
2146
+ path: "*",
2147
+ element: /* @__PURE__ */ jsx(LoginPage, {
2148
+ apiBaseUrl,
2149
+ title: config.login?.title ?? config.title,
2150
+ hint: config.login?.hint,
2151
+ defaultUsername: config.login?.defaultUsername,
2152
+ oidcProviders: config.login?.oidcProviders,
2153
+ onLoggedIn: () => void session.refresh()
2154
+ })
2155
+ })
2156
+ ] }) });
1448
2157
  }
1449
2158
  function createNubitApp(config) {
1450
2159
  const queryClient = config.queryClient ?? new QueryClient({ defaultOptions: { queries: {
@@ -1467,4 +2176,4 @@ function createNubitApp(config) {
1467
2176
  return { App };
1468
2177
  }
1469
2178
  //#endregion
1470
- export { AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, FeatureHubLayout, LoginPage, NotificationPanel, NubitDevToolsPanel, PlanPanel, QuotaUsageBanner, RegisterPage, SessionProvider, StaticSessionProvider, ToastHost, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useNotifications, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
2179
+ export { AcceptInvitationPage, AccountPage, AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, FeatureHubLayout, ForgotPasswordPage, LoginPage, NotificationPanel, NubitDevToolsPanel, PlanPanel, QuotaUsageBanner, RegisterPage, ResetPasswordPage, SessionProvider, StaticSessionProvider, ToastHost, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useNotifications, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };