@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.cjs CHANGED
@@ -464,14 +464,14 @@ const AdminShell = ({ title, menuItems, headerActions, renderUserMenu, renderThe
464
464
  //#endregion
465
465
  //#region packages/admin/auth/SessionContext.tsx
466
466
  const SessionContext = (0, react.createContext)(null);
467
- function joinApiPath$3(apiBaseUrl, path) {
467
+ function joinApiPath$7(apiBaseUrl, path) {
468
468
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
469
469
  }
470
470
  function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "auth/logout", children }) {
471
471
  const [session, setSession] = (0, react.useState)({ status: "loading" });
472
472
  const refresh = (0, react.useCallback)(async () => {
473
473
  try {
474
- const response = await fetch(joinApiPath$3(apiBaseUrl, mePath), { credentials: "include" });
474
+ const response = await fetch(joinApiPath$7(apiBaseUrl, mePath), { credentials: "include" });
475
475
  if (!response.ok) {
476
476
  setSession({ status: "anonymous" });
477
477
  return;
@@ -488,7 +488,7 @@ function SessionProvider({ apiBaseUrl = "/api/", mePath = "me", logoutPath = "au
488
488
  refresh();
489
489
  }, [refresh]);
490
490
  const logout = (0, react.useCallback)(async () => {
491
- await fetch(joinApiPath$3(apiBaseUrl, logoutPath), {
491
+ await fetch(joinApiPath$7(apiBaseUrl, logoutPath), {
492
492
  method: "POST",
493
493
  credentials: "include"
494
494
  });
@@ -854,12 +854,15 @@ function NotificationPanel({ title = "Notifications", emptyTitle = "You're all c
854
854
  }
855
855
  //#endregion
856
856
  //#region packages/admin/auth/LoginPage.tsx
857
- function joinApiPath$2(apiBaseUrl, path) {
857
+ function joinApiPath$6(apiBaseUrl, path) {
858
858
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
859
859
  }
860
- function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login", title = "Nubit Admin", hint, defaultUsername = "" }) {
860
+ const TOTP_REQUIRED = "second factor required.";
861
+ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login", title = "Nubit Admin", hint, defaultUsername = "", oidcProviders = [], forgotPasswordTo = "/forgot-password" }) {
861
862
  const [username, setUsername] = (0, react.useState)(defaultUsername);
862
863
  const [password, setPassword] = (0, react.useState)("");
864
+ const [totpCode, setTotpCode] = (0, react.useState)("");
865
+ const [needsTotp, setNeedsTotp] = (0, react.useState)(false);
863
866
  const [error, setError] = (0, react.useState)(null);
864
867
  const [busy, setBusy] = (0, react.useState)(false);
865
868
  const submit = async (event) => {
@@ -867,17 +870,25 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
867
870
  setBusy(true);
868
871
  setError(null);
869
872
  try {
870
- const response = await fetch(joinApiPath$2(apiBaseUrl, loginPath), {
873
+ const body = {
874
+ username,
875
+ password
876
+ };
877
+ if (totpCode !== "") body.totpCode = totpCode;
878
+ const response = await fetch(joinApiPath$6(apiBaseUrl, loginPath), {
871
879
  method: "POST",
872
880
  headers: { "Content-Type": "application/json" },
873
881
  credentials: "include",
874
- body: JSON.stringify({
875
- username,
876
- password
877
- })
882
+ body: JSON.stringify(body)
878
883
  });
879
884
  if (!response.ok) {
880
- setError((await response.json().catch(() => null))?.message ?? "Login failed");
885
+ const message = (await response.json().catch(() => null))?.message ?? "Login failed";
886
+ if (message.toLowerCase() === TOTP_REQUIRED) {
887
+ setNeedsTotp(true);
888
+ setError("Enter the code from your authenticator app.");
889
+ return;
890
+ }
891
+ setError(message);
881
892
  return;
882
893
  }
883
894
  onLoggedIn();
@@ -914,24 +925,41 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
914
925
  },
915
926
  children: hint
916
927
  }),
917
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
918
- placeholder: "Email",
919
- value: username,
920
- autoComplete: "username",
921
- onChange: (e) => setUsername(e.target.value)
928
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
929
+ label: "Email",
930
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
931
+ placeholder: "Email",
932
+ value: username,
933
+ autoComplete: "username",
934
+ onChange: (e) => setUsername(e.target.value)
935
+ })
922
936
  }),
923
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
924
- placeholder: "Password",
925
- type: "password",
926
- value: password,
927
- autoComplete: "current-password",
928
- onChange: (e) => setPassword(e.target.value)
937
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
938
+ label: "Password",
939
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
940
+ placeholder: "Password",
941
+ type: "password",
942
+ value: password,
943
+ autoComplete: "current-password",
944
+ onChange: (e) => setPassword(e.target.value)
945
+ })
946
+ }),
947
+ needsTotp && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
948
+ label: "Authenticator code",
949
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
950
+ placeholder: "123456",
951
+ value: totpCode,
952
+ autoComplete: "one-time-code",
953
+ inputMode: "numeric",
954
+ onChange: (e) => setTotpCode(e.target.value)
955
+ })
929
956
  }),
930
957
  error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
931
958
  style: {
932
959
  margin: 0,
933
960
  color: "var(--error-color, #dc2626)"
934
961
  },
962
+ role: "alert",
935
963
  children: error
936
964
  }),
937
965
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
@@ -939,12 +967,587 @@ function LoginPage({ onLoggedIn, apiBaseUrl = "/api/", loginPath = "auth/login",
939
967
  type: "submit",
940
968
  disabled: busy,
941
969
  children: busy ? "Signing in…" : "Sign in"
970
+ }),
971
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Link, {
972
+ to: forgotPasswordTo,
973
+ style: { fontSize: "0.875rem" },
974
+ children: "Forgot password?"
975
+ }),
976
+ oidcProviders.map((provider) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
977
+ variant: "secondary",
978
+ type: "button",
979
+ onClick: () => {
980
+ window.location.assign(joinApiPath$6(apiBaseUrl, `auth/oidc/${provider.id}/redirect`));
981
+ },
982
+ children: provider.label
983
+ }, provider.id))
984
+ ]
985
+ }) })
986
+ });
987
+ }
988
+ //#endregion
989
+ //#region packages/admin/auth/ForgotPasswordPage.tsx
990
+ function joinApiPath$5(apiBaseUrl, path) {
991
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
992
+ }
993
+ function ForgotPasswordPage({ apiBaseUrl = "/api/" }) {
994
+ const [username, setUsername] = (0, react.useState)("");
995
+ const [sent, setSent] = (0, react.useState)(false);
996
+ const [busy, setBusy] = (0, react.useState)(false);
997
+ const submit = async (event) => {
998
+ event.preventDefault();
999
+ setBusy(true);
1000
+ try {
1001
+ await fetch(joinApiPath$5(apiBaseUrl, "auth/password/forgot"), {
1002
+ method: "POST",
1003
+ headers: { "Content-Type": "application/json" },
1004
+ credentials: "include",
1005
+ body: JSON.stringify({ username })
1006
+ });
1007
+ setSent(true);
1008
+ } finally {
1009
+ setBusy(false);
1010
+ }
1011
+ };
1012
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1013
+ style: {
1014
+ display: "grid",
1015
+ placeItems: "center",
1016
+ minHeight: "100vh"
1017
+ },
1018
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Card, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1019
+ onSubmit: submit,
1020
+ style: {
1021
+ display: "flex",
1022
+ flexDirection: "column",
1023
+ gap: 12,
1024
+ width: 320,
1025
+ padding: 8
1026
+ },
1027
+ children: [
1028
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1029
+ style: { margin: 0 },
1030
+ children: "Reset password"
1031
+ }),
1032
+ sent ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1033
+ style: {
1034
+ margin: 0,
1035
+ color: "var(--text-secondary)"
1036
+ },
1037
+ children: "If that address has an account, a reset link is on its way."
1038
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
1039
+ label: "Email",
1040
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
1041
+ value: username,
1042
+ autoComplete: "username",
1043
+ onChange: (e) => setUsername(e.target.value)
1044
+ })
1045
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
1046
+ variant: "primary",
1047
+ type: "submit",
1048
+ disabled: busy || username === "",
1049
+ children: busy ? "Sending…" : "Send reset link"
1050
+ })] }),
1051
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Link, {
1052
+ to: "/",
1053
+ style: { fontSize: "0.875rem" },
1054
+ children: "Back to sign in"
942
1055
  })
943
1056
  ]
944
1057
  }) })
945
1058
  });
946
1059
  }
947
1060
  //#endregion
1061
+ //#region packages/admin/auth/ResetPasswordPage.tsx
1062
+ function joinApiPath$4(apiBaseUrl, path) {
1063
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
1064
+ }
1065
+ function ResetPasswordPage({ apiBaseUrl = "/api/" }) {
1066
+ const [params] = (0, react_router_dom.useSearchParams)();
1067
+ const token = params.get("token") ?? "";
1068
+ const [password, setPassword] = (0, react.useState)("");
1069
+ const [error, setError] = (0, react.useState)(null);
1070
+ const [done, setDone] = (0, react.useState)(false);
1071
+ const [busy, setBusy] = (0, react.useState)(false);
1072
+ const submit = async (event) => {
1073
+ event.preventDefault();
1074
+ setBusy(true);
1075
+ setError(null);
1076
+ try {
1077
+ const response = await fetch(joinApiPath$4(apiBaseUrl, "auth/password/reset"), {
1078
+ method: "POST",
1079
+ headers: { "Content-Type": "application/json" },
1080
+ credentials: "include",
1081
+ body: JSON.stringify({
1082
+ token,
1083
+ password
1084
+ })
1085
+ });
1086
+ if (!response.ok) {
1087
+ setError((await response.json().catch(() => null))?.message ?? "Reset failed");
1088
+ return;
1089
+ }
1090
+ setDone(true);
1091
+ } catch {
1092
+ setError("Network error");
1093
+ } finally {
1094
+ setBusy(false);
1095
+ }
1096
+ };
1097
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1098
+ style: {
1099
+ display: "grid",
1100
+ placeItems: "center",
1101
+ minHeight: "100vh"
1102
+ },
1103
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Card, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1104
+ onSubmit: submit,
1105
+ style: {
1106
+ display: "flex",
1107
+ flexDirection: "column",
1108
+ gap: 12,
1109
+ width: 320,
1110
+ padding: 8
1111
+ },
1112
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1113
+ style: { margin: 0 },
1114
+ children: "Choose a new password"
1115
+ }), done ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1116
+ style: { margin: 0 },
1117
+ children: [
1118
+ "Password updated.",
1119
+ " ",
1120
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Link, {
1121
+ to: "/",
1122
+ style: { fontSize: "0.875rem" },
1123
+ children: "Sign in"
1124
+ })
1125
+ ]
1126
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1127
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
1128
+ label: "New password",
1129
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
1130
+ type: "password",
1131
+ value: password,
1132
+ autoComplete: "new-password",
1133
+ minLength: 8,
1134
+ onChange: (e) => setPassword(e.target.value)
1135
+ })
1136
+ }),
1137
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1138
+ style: {
1139
+ margin: 0,
1140
+ color: "var(--error-color, #dc2626)"
1141
+ },
1142
+ role: "alert",
1143
+ children: error
1144
+ }),
1145
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
1146
+ variant: "primary",
1147
+ type: "submit",
1148
+ disabled: busy || token === "" || password.length < 8,
1149
+ children: busy ? "Saving…" : "Update password"
1150
+ })
1151
+ ] })]
1152
+ }) })
1153
+ });
1154
+ }
1155
+ //#endregion
1156
+ //#region packages/admin/auth/AcceptInvitationPage.tsx
1157
+ function joinApiPath$3(apiBaseUrl, path) {
1158
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
1159
+ }
1160
+ function AcceptInvitationPage({ apiBaseUrl = "/api/" }) {
1161
+ const { token = "" } = (0, react_router_dom.useParams)();
1162
+ const [email, setEmail] = (0, react.useState)(null);
1163
+ const [password, setPassword] = (0, react.useState)("");
1164
+ const [error, setError] = (0, react.useState)(null);
1165
+ const [done, setDone] = (0, react.useState)(false);
1166
+ const [busy, setBusy] = (0, react.useState)(false);
1167
+ (0, react.useEffect)(() => {
1168
+ let cancelled = false;
1169
+ fetch(joinApiPath$3(apiBaseUrl, `invitations/${token}`), { credentials: "include" }).then(async (response) => {
1170
+ if (cancelled) return;
1171
+ if (!response.ok) {
1172
+ setError("This invitation is no longer valid.");
1173
+ return;
1174
+ }
1175
+ setEmail((await response.json()).email ?? null);
1176
+ }).catch(() => {
1177
+ if (!cancelled) setError("Network error");
1178
+ });
1179
+ return () => {
1180
+ cancelled = true;
1181
+ };
1182
+ }, [apiBaseUrl, token]);
1183
+ const submit = async (event) => {
1184
+ event.preventDefault();
1185
+ setBusy(true);
1186
+ setError(null);
1187
+ try {
1188
+ const response = await fetch(joinApiPath$3(apiBaseUrl, `invitations/${token}/accept`), {
1189
+ method: "POST",
1190
+ headers: { "Content-Type": "application/json" },
1191
+ credentials: "include",
1192
+ body: JSON.stringify({ password })
1193
+ });
1194
+ if (!response.ok) {
1195
+ setError((await response.json().catch(() => null))?.message ?? "Could not accept the invitation");
1196
+ return;
1197
+ }
1198
+ setDone(true);
1199
+ } catch {
1200
+ setError("Network error");
1201
+ } finally {
1202
+ setBusy(false);
1203
+ }
1204
+ };
1205
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1206
+ style: {
1207
+ display: "grid",
1208
+ placeItems: "center",
1209
+ minHeight: "100vh"
1210
+ },
1211
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Card, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1212
+ onSubmit: submit,
1213
+ style: {
1214
+ display: "flex",
1215
+ flexDirection: "column",
1216
+ gap: 12,
1217
+ width: 320,
1218
+ padding: 8
1219
+ },
1220
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1221
+ style: { margin: 0 },
1222
+ children: "Join"
1223
+ }), done ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1224
+ style: { margin: 0 },
1225
+ children: [
1226
+ "Account created.",
1227
+ " ",
1228
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Link, {
1229
+ to: "/",
1230
+ style: { fontSize: "0.875rem" },
1231
+ children: "Sign in"
1232
+ })
1233
+ ]
1234
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1235
+ email && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1236
+ style: {
1237
+ margin: 0,
1238
+ color: "var(--text-secondary)"
1239
+ },
1240
+ children: email
1241
+ }),
1242
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
1243
+ label: "Password",
1244
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
1245
+ type: "password",
1246
+ value: password,
1247
+ autoComplete: "new-password",
1248
+ minLength: 8,
1249
+ onChange: (e) => setPassword(e.target.value)
1250
+ })
1251
+ }),
1252
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1253
+ style: {
1254
+ margin: 0,
1255
+ color: "var(--error-color, #dc2626)"
1256
+ },
1257
+ role: "alert",
1258
+ children: error
1259
+ }),
1260
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
1261
+ variant: "primary",
1262
+ type: "submit",
1263
+ disabled: busy || password.length < 8,
1264
+ children: busy ? "Creating…" : "Create account"
1265
+ })
1266
+ ] })]
1267
+ }) })
1268
+ });
1269
+ }
1270
+ //#endregion
1271
+ //#region packages/admin/auth/AccountPage.tsx
1272
+ function joinApiPath$2(apiBaseUrl, path) {
1273
+ return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
1274
+ }
1275
+ function AccountPage({ apiBaseUrl = "/api/" }) {
1276
+ const { username, refresh } = useSession();
1277
+ const [currentPassword, setCurrentPassword] = (0, react.useState)("");
1278
+ const [newPassword, setNewPassword] = (0, react.useState)("");
1279
+ const [passwordMessage, setPasswordMessage] = (0, react.useState)(null);
1280
+ const [totp, setTotp] = (0, react.useState)(null);
1281
+ const [totpCode, setTotpCode] = (0, react.useState)("");
1282
+ const [totpSecret, setTotpSecret] = (0, react.useState)(null);
1283
+ const [totpMessage, setTotpMessage] = (0, react.useState)(null);
1284
+ const [sessions, setSessions] = (0, react.useState)([]);
1285
+ const load = (0, react.useCallback)(async (signal) => {
1286
+ const [totpResponse, sessionResponse] = await Promise.all([fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1287
+ credentials: "include",
1288
+ signal
1289
+ }), fetch(joinApiPath$2(apiBaseUrl, "auth/sessions"), {
1290
+ credentials: "include",
1291
+ signal
1292
+ })]);
1293
+ if (signal?.aborted) return;
1294
+ if (totpResponse.ok) setTotp(await totpResponse.json());
1295
+ if (sessionResponse.ok) setSessions((await sessionResponse.json()).sessions ?? []);
1296
+ }, [apiBaseUrl]);
1297
+ (0, react.useEffect)(() => {
1298
+ const controller = new AbortController();
1299
+ load(controller.signal).catch((error) => {
1300
+ if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
1301
+ });
1302
+ return () => controller.abort();
1303
+ }, [load]);
1304
+ const changePassword = async (event) => {
1305
+ event.preventDefault();
1306
+ setPasswordMessage(null);
1307
+ const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/change-password"), {
1308
+ method: "POST",
1309
+ headers: { "Content-Type": "application/json" },
1310
+ credentials: "include",
1311
+ body: JSON.stringify({
1312
+ currentPassword,
1313
+ newPassword
1314
+ })
1315
+ });
1316
+ if (!response.ok) {
1317
+ setPasswordMessage((await response.json().catch(() => null))?.message ?? "Could not change password");
1318
+ return;
1319
+ }
1320
+ setCurrentPassword("");
1321
+ setNewPassword("");
1322
+ setPasswordMessage("Password updated.");
1323
+ await refresh();
1324
+ };
1325
+ const beginTotp = async () => {
1326
+ setTotpMessage(null);
1327
+ const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1328
+ method: "POST",
1329
+ credentials: "include"
1330
+ });
1331
+ if (!response.ok) {
1332
+ setTotpMessage((await response.json().catch(() => null))?.message ?? "Could not start enrolment");
1333
+ return;
1334
+ }
1335
+ const payload = await response.json();
1336
+ setTotpSecret(payload.uri ?? payload.secret ?? null);
1337
+ await load();
1338
+ };
1339
+ const confirmTotp = async (event) => {
1340
+ event.preventDefault();
1341
+ const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp/confirm"), {
1342
+ method: "POST",
1343
+ headers: { "Content-Type": "application/json" },
1344
+ credentials: "include",
1345
+ body: JSON.stringify({ code: totpCode })
1346
+ });
1347
+ if (!response.ok) {
1348
+ setTotpMessage((await response.json().catch(() => null))?.message ?? "That code is not valid");
1349
+ return;
1350
+ }
1351
+ setTotpCode("");
1352
+ setTotpSecret(null);
1353
+ setTotpMessage("Authenticator enrolled.");
1354
+ await load();
1355
+ };
1356
+ const disableTotp = async (event) => {
1357
+ event.preventDefault();
1358
+ const response = await fetch(joinApiPath$2(apiBaseUrl, "auth/totp"), {
1359
+ method: "DELETE",
1360
+ headers: { "Content-Type": "application/json" },
1361
+ credentials: "include",
1362
+ body: JSON.stringify({ code: totpCode })
1363
+ });
1364
+ if (!response.ok) {
1365
+ setTotpMessage((await response.json().catch(() => null))?.message ?? "Could not disable the second factor");
1366
+ return;
1367
+ }
1368
+ setTotpCode("");
1369
+ setTotpMessage("Second factor removed.");
1370
+ await load();
1371
+ };
1372
+ const revokeSession = async (id) => {
1373
+ await fetch(joinApiPath$2(apiBaseUrl, `auth/sessions/${id}`), {
1374
+ method: "DELETE",
1375
+ credentials: "include"
1376
+ });
1377
+ await load();
1378
+ };
1379
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1380
+ style: {
1381
+ display: "grid",
1382
+ gap: 24,
1383
+ maxWidth: 480,
1384
+ padding: 24
1385
+ },
1386
+ children: [
1387
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
1388
+ style: { margin: 0 },
1389
+ children: "Account"
1390
+ }),
1391
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1392
+ style: {
1393
+ margin: 0,
1394
+ color: "var(--text-secondary)"
1395
+ },
1396
+ children: username
1397
+ }),
1398
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Card, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1399
+ onSubmit: changePassword,
1400
+ style: {
1401
+ display: "flex",
1402
+ flexDirection: "column",
1403
+ gap: 12,
1404
+ padding: 8
1405
+ },
1406
+ children: [
1407
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1408
+ style: {
1409
+ margin: 0,
1410
+ fontSize: "1rem"
1411
+ },
1412
+ children: "Password"
1413
+ }),
1414
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
1415
+ label: "Current password",
1416
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
1417
+ type: "password",
1418
+ value: currentPassword,
1419
+ autoComplete: "current-password",
1420
+ onChange: (e) => setCurrentPassword(e.target.value)
1421
+ })
1422
+ }),
1423
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
1424
+ label: "New password",
1425
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
1426
+ type: "password",
1427
+ value: newPassword,
1428
+ autoComplete: "new-password",
1429
+ minLength: 8,
1430
+ onChange: (e) => setNewPassword(e.target.value)
1431
+ })
1432
+ }),
1433
+ passwordMessage && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1434
+ style: { margin: 0 },
1435
+ children: passwordMessage
1436
+ }),
1437
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
1438
+ variant: "primary",
1439
+ type: "submit",
1440
+ disabled: newPassword.length < 8,
1441
+ children: "Update password"
1442
+ })
1443
+ ]
1444
+ }) }),
1445
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Card, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1446
+ style: {
1447
+ display: "flex",
1448
+ flexDirection: "column",
1449
+ gap: 12,
1450
+ padding: 8
1451
+ },
1452
+ children: [
1453
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1454
+ style: {
1455
+ margin: 0,
1456
+ fontSize: "1rem"
1457
+ },
1458
+ children: "Authenticator"
1459
+ }),
1460
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1461
+ style: {
1462
+ margin: 0,
1463
+ color: "var(--text-secondary)"
1464
+ },
1465
+ children: totp?.enrolled ? "A second factor is enrolled on this account." : "No second factor yet."
1466
+ }),
1467
+ totpSecret && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1468
+ style: {
1469
+ margin: 0,
1470
+ wordBreak: "break-all",
1471
+ fontSize: "0.75rem"
1472
+ },
1473
+ children: totpSecret
1474
+ }),
1475
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1476
+ onSubmit: totp?.enrolled ? disableTotp : totpSecret ? confirmTotp : beginTotp,
1477
+ style: {
1478
+ display: "flex",
1479
+ flexDirection: "column",
1480
+ gap: 12
1481
+ },
1482
+ children: [
1483
+ (totp?.enrolled || totpSecret) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.FormField, {
1484
+ label: "Authenticator code",
1485
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.TextField, {
1486
+ value: totpCode,
1487
+ autoComplete: "one-time-code",
1488
+ inputMode: "numeric",
1489
+ onChange: (e) => setTotpCode(e.target.value)
1490
+ })
1491
+ }),
1492
+ totpMessage && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1493
+ style: { margin: 0 },
1494
+ children: totpMessage
1495
+ }),
1496
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
1497
+ variant: "secondary",
1498
+ type: "submit",
1499
+ children: totp?.enrolled ? "Disable" : totpSecret ? "Confirm" : "Enrol"
1500
+ })
1501
+ ]
1502
+ })
1503
+ ]
1504
+ }) }),
1505
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Card, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1506
+ style: {
1507
+ display: "flex",
1508
+ flexDirection: "column",
1509
+ gap: 12,
1510
+ padding: 8
1511
+ },
1512
+ children: [
1513
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1514
+ style: {
1515
+ margin: 0,
1516
+ fontSize: "1rem"
1517
+ },
1518
+ children: "Sessions"
1519
+ }),
1520
+ sessions.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1521
+ style: {
1522
+ margin: 0,
1523
+ color: "var(--text-secondary)"
1524
+ },
1525
+ children: "No other sessions."
1526
+ }),
1527
+ sessions.map((session) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1528
+ style: {
1529
+ display: "flex",
1530
+ justifyContent: "space-between",
1531
+ gap: 12,
1532
+ alignItems: "center"
1533
+ },
1534
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1535
+ style: { fontSize: "0.875rem" },
1536
+ children: session.lastUsedAt ?? session.createdAt ?? `Session ${session.id}`
1537
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
1538
+ variant: "secondary",
1539
+ size: "sm",
1540
+ type: "button",
1541
+ onClick: () => void revokeSession(session.id),
1542
+ children: "Revoke"
1543
+ })]
1544
+ }, session.id))
1545
+ ]
1546
+ }) })
1547
+ ]
1548
+ });
1549
+ }
1550
+ //#endregion
948
1551
  //#region packages/admin/auth/RegisterPage.tsx
949
1552
  function joinApiPath$1(apiBaseUrl, path) {
950
1553
  return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
@@ -1312,6 +1915,54 @@ function NubitDevToolsPanel() {
1312
1915
  });
1313
1916
  }
1314
1917
  //#endregion
1918
+ //#region packages/admin/app/ErrorBoundary.tsx
1919
+ /**
1920
+ * Keeps a thrown field renderer from taking the whole admin down with it.
1921
+ */
1922
+ var ErrorBoundary = class extends react.Component {
1923
+ state = { error: null };
1924
+ static getDerivedStateFromError(error) {
1925
+ return { error };
1926
+ }
1927
+ componentDidCatch(error, info) {
1928
+ console.error("Nubit admin render error", error, info.componentStack);
1929
+ }
1930
+ render() {
1931
+ if (!this.state.error) return this.props.children;
1932
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1933
+ role: "alert",
1934
+ style: {
1935
+ padding: 24,
1936
+ maxWidth: 480
1937
+ },
1938
+ children: [
1939
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
1940
+ style: {
1941
+ margin: "0 0 8px",
1942
+ fontSize: "1.25rem"
1943
+ },
1944
+ children: "Something went wrong"
1945
+ }),
1946
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1947
+ style: {
1948
+ margin: "0 0 16px",
1949
+ color: "var(--text-secondary)"
1950
+ },
1951
+ children: "Reload the page. If it happens again, the last action is in the browser console."
1952
+ }),
1953
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
1954
+ variant: "primary",
1955
+ onClick: () => {
1956
+ this.setState({ error: null });
1957
+ window.location.reload();
1958
+ },
1959
+ children: "Reload"
1960
+ })
1961
+ ]
1962
+ });
1963
+ }
1964
+ };
1965
+ //#endregion
1315
1966
  //#region packages/admin/app/filterMenuByRoles.ts
1316
1967
  function hasAnyRole(required, roles) {
1317
1968
  if (required === void 0) return true;
@@ -1360,20 +2011,28 @@ function defaultUserMenu({ username, close, logout }) {
1360
2011
  gap: 8,
1361
2012
  minWidth: 180
1362
2013
  },
1363
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1364
- style: {
1365
- color: "var(--text-secondary)",
1366
- fontSize: "0.875rem"
1367
- },
1368
- children: username ?? "User"
1369
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1370
- type: "button",
1371
- onClick: () => {
1372
- close();
1373
- logout();
1374
- },
1375
- children: "Sign out"
1376
- })]
2014
+ children: [
2015
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2016
+ style: {
2017
+ color: "var(--text-secondary)",
2018
+ fontSize: "0.875rem"
2019
+ },
2020
+ children: username ?? "User"
2021
+ }),
2022
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Link, {
2023
+ to: "/account",
2024
+ onClick: close,
2025
+ children: "Account"
2026
+ }),
2027
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2028
+ type: "button",
2029
+ onClick: () => {
2030
+ close();
2031
+ logout();
2032
+ },
2033
+ children: "Sign out"
2034
+ })
2035
+ ]
1377
2036
  });
1378
2037
  }
1379
2038
  function buildMenuContext(session) {
@@ -1410,7 +2069,25 @@ function NubitAuthenticatedApp({ config }) {
1410
2069
  const renderThemeSwitcher = config.renderThemeSwitcher ?? (() => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.ThemeSwitcher, {}));
1411
2070
  const renderUserMenu = config.renderUserMenu ?? defaultUserMenu;
1412
2071
  const Wrapper = config.Wrapper ?? react.default.Fragment;
1413
- if (session.session.status === "loading") return null;
2072
+ const profile = session.session.status === "authenticated" ? session.session.profile : void 0;
2073
+ if (session.session.status === "loading") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2074
+ style: { padding: 24 },
2075
+ "aria-busy": "true",
2076
+ "aria-label": "Loading session",
2077
+ children: [
2078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Skeleton, {
2079
+ variant: "rect",
2080
+ height: 32,
2081
+ width: "33%"
2082
+ }),
2083
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { style: { height: 16 } }),
2084
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Skeleton, {
2085
+ variant: "rect",
2086
+ height: 256
2087
+ })
2088
+ ]
2089
+ });
2090
+ const hasAccountRoute = config.routes.some((route) => route.path === "/account");
1414
2091
  const shell = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminShell, {
1415
2092
  title: config.title,
1416
2093
  menuItems,
@@ -1421,54 +2098,86 @@ function NubitAuthenticatedApp({ config }) {
1421
2098
  ...menuContext,
1422
2099
  close
1423
2100
  }),
1424
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_router_dom.Routes, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
1425
- path: "/",
1426
- element: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Navigate, {
1427
- to: homePath,
1428
- replace: true
2101
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_router_dom.Routes, { children: [
2102
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
2103
+ path: "/",
2104
+ element: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Navigate, {
2105
+ to: homePath,
2106
+ replace: true
2107
+ })
2108
+ }),
2109
+ config.routes.map((route) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
2110
+ path: route.path,
2111
+ element: route.element
2112
+ }, route.path)),
2113
+ !hasAccountRoute && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
2114
+ path: "/account",
2115
+ element: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountPage, { apiBaseUrl })
1429
2116
  })
1430
- }), config.routes.map((route) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
1431
- path: route.path,
1432
- element: route.element
1433
- }, route.path))] })
2117
+ ] })
1434
2118
  });
1435
2119
  const devToolsEnabled = config.devTools ?? (0, _nubitio_crud.isDevEnvironment)();
1436
- const authenticated = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_crud.DevToolsProvider, {
2120
+ const authenticated = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBoundary, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_crud.DevToolsProvider, {
1437
2121
  enabled: devToolsEnabled,
1438
2122
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_core.CoreProvider, {
1439
2123
  http: {
1440
2124
  baseUrl: apiBaseUrl,
1441
2125
  refreshPath: "auth/refresh",
1442
- loginPath: "auth/login"
2126
+ loginPath: "auth/login",
2127
+ onUnauthorized: () => {
2128
+ session.logout();
2129
+ }
1443
2130
  },
1444
2131
  runtime,
1445
2132
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_core.CoreConfigProvider, {
1446
2133
  apiBaseUrl,
1447
2134
  locale: config.locale ?? "en",
1448
- timezone: config.timezone ?? "UTC",
2135
+ timezone: profile?.timeZone ?? config.timezone ?? "UTC",
1449
2136
  currency: config.currency ?? "USD",
1450
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_nubitio_crud.SmartCrudRolesProvider, {
2137
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_crud.SmartCrudRolesProvider, {
1451
2138
  roles: session.roles,
1452
- children: [
1453
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.BrowserRouter, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Wrapper, { children: config.hydra === false ? shell : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_core.MercureProvider, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_hydra.SchemaProvider, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_hydra.HydraResourceSchemaProvider, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_hydra.HydraResourceStoreProvider, { children: shell }) }) }) }) }) }),
1454
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToastHost, {
1455
- toasts,
1456
- onDismiss: dismiss
1457
- }),
1458
- devToolsEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NubitDevToolsPanel, {})
1459
- ]
2139
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_nubitio_crud.SessionPermissionsProvider, {
2140
+ permissions: profile?.permissions,
2141
+ limits: profile?.limits,
2142
+ children: [
2143
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.BrowserRouter, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Wrapper, { children: config.hydra === false ? shell : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_core.MercureProvider, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_hydra.SchemaProvider, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_hydra.HydraResourceSchemaProvider, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_hydra.HydraResourceStoreProvider, { children: shell }) }) }) }) }) }),
2144
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToastHost, {
2145
+ toasts,
2146
+ onDismiss: dismiss
2147
+ }),
2148
+ devToolsEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NubitDevToolsPanel, {})
2149
+ ]
2150
+ })
1460
2151
  })
1461
2152
  })
1462
2153
  })
1463
- });
1464
- if (session.session.status === "authenticated") return authenticated;
1465
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.BrowserRouter, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoginPage, {
1466
- apiBaseUrl,
1467
- title: config.login?.title ?? config.title,
1468
- hint: config.login?.hint,
1469
- defaultUsername: config.login?.defaultUsername,
1470
- onLoggedIn: () => void session.refresh()
1471
2154
  }) });
2155
+ if (session.session.status === "authenticated") return authenticated;
2156
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.BrowserRouter, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_router_dom.Routes, { children: [
2157
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
2158
+ path: "/forgot-password",
2159
+ element: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ForgotPasswordPage, { apiBaseUrl })
2160
+ }),
2161
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
2162
+ path: "/reset-password",
2163
+ element: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ResetPasswordPage, { apiBaseUrl })
2164
+ }),
2165
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
2166
+ path: "/invitations/:token/accept",
2167
+ element: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AcceptInvitationPage, { apiBaseUrl })
2168
+ }),
2169
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_router_dom.Route, {
2170
+ path: "*",
2171
+ element: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoginPage, {
2172
+ apiBaseUrl,
2173
+ title: config.login?.title ?? config.title,
2174
+ hint: config.login?.hint,
2175
+ defaultUsername: config.login?.defaultUsername,
2176
+ oidcProviders: config.login?.oidcProviders,
2177
+ onLoggedIn: () => void session.refresh()
2178
+ })
2179
+ })
2180
+ ] }) });
1472
2181
  }
1473
2182
  function createNubitApp(config) {
1474
2183
  const queryClient = config.queryClient ?? new _tanstack_react_query.QueryClient({ defaultOptions: { queries: {
@@ -1491,17 +2200,21 @@ function createNubitApp(config) {
1491
2200
  return { App };
1492
2201
  }
1493
2202
  //#endregion
2203
+ exports.AcceptInvitationPage = AcceptInvitationPage;
2204
+ exports.AccountPage = AccountPage;
1494
2205
  exports.AdminHeader = AdminHeader;
1495
2206
  exports.AdminShell = AdminShell;
1496
2207
  exports.AdminSidebarMenu = AdminSidebarMenu;
1497
2208
  exports.FeatureGate = FeatureGate;
1498
2209
  exports.FeatureHubLayout = FeatureHubLayout;
2210
+ exports.ForgotPasswordPage = ForgotPasswordPage;
1499
2211
  exports.LoginPage = LoginPage;
1500
2212
  exports.NotificationPanel = NotificationPanel;
1501
2213
  exports.NubitDevToolsPanel = NubitDevToolsPanel;
1502
2214
  exports.PlanPanel = PlanPanel;
1503
2215
  exports.QuotaUsageBanner = QuotaUsageBanner;
1504
2216
  exports.RegisterPage = RegisterPage;
2217
+ exports.ResetPasswordPage = ResetPasswordPage;
1505
2218
  exports.SessionProvider = SessionProvider;
1506
2219
  exports.StaticSessionProvider = StaticSessionProvider;
1507
2220
  exports.ToastHost = ToastHost;