@robosystems/core 0.6.2 → 0.6.3

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.
@@ -6,4 +6,11 @@ interface AuthProviderProps {
6
6
  }
7
7
  export declare function AuthProvider({ children, apiUrl, }: AuthProviderProps): import("react").JSX.Element;
8
8
  export declare function useAuth(): AuthContextType;
9
+ /**
10
+ * Like `useAuth`, but returns `null` outside an `AuthProvider` instead of
11
+ * throwing. For components that integrate with auth when it is present but must
12
+ * still render without it — design-system previews and isolated component tests
13
+ * mount components with no provider around them.
14
+ */
15
+ export declare function useOptionalAuth(): AuthContextType | null;
9
16
  export {};
@@ -562,3 +562,12 @@ export function useAuth() {
562
562
  }
563
563
  return context;
564
564
  }
565
+ /**
566
+ * Like `useAuth`, but returns `null` outside an `AuthProvider` instead of
567
+ * throwing. For components that integrate with auth when it is present but must
568
+ * still render without it — design-system previews and isolated component tests
569
+ * mount components with no provider around them.
570
+ */
571
+ export function useOptionalAuth() {
572
+ return useContext(AuthContext);
573
+ }
@@ -17,4 +17,10 @@ export interface SignInFormProps {
17
17
  * auth body) stays "Invalid email or password"; 5xx gets its own message.
18
18
  */
19
19
  export declare function loginErrorMessage(error: unknown): string;
20
+ /**
21
+ * Messages for the `?reason=` a redirect into the login page can carry. The
22
+ * producer is `AuthProvider`'s `logout(reason)`, which sends the user to
23
+ * `/login?reason=<reason>`; without this the redirect arrives unexplained.
24
+ */
25
+ export declare const SIGN_IN_NOTICES: Record<string, string>;
20
26
  export declare function SignInForm({ onSuccess, onRedirect, redirectTo, className, apiUrl, enableSSO, currentApp, }: SignInFormProps): React.JSX.Element;
@@ -26,6 +26,16 @@ export function loginErrorMessage(error) {
26
26
  }
27
27
  return 'Invalid email or password';
28
28
  }
29
+ /**
30
+ * Messages for the `?reason=` a redirect into the login page can carry. The
31
+ * producer is `AuthProvider`'s `logout(reason)`, which sends the user to
32
+ * `/login?reason=<reason>`; without this the redirect arrives unexplained.
33
+ */
34
+ export const SIGN_IN_NOTICES = {
35
+ password_changed: 'Your password was updated. Please sign in with your new password.',
36
+ session_expired: 'Your session expired. Please sign in again.',
37
+ session_invalid: 'Your session is no longer valid. Please sign in again.',
38
+ };
29
39
  export function SignInForm({ onSuccess, onRedirect, redirectTo = '/home', className = '', apiUrl, enableSSO = true, currentApp, }) {
30
40
  const [formData, setFormData] = useState({
31
41
  email: '',
@@ -35,6 +45,17 @@ export function SignInForm({ onSuccess, onRedirect, redirectTo = '/home', classN
35
45
  const [error, setError] = useState('');
36
46
  const [ssoChecking, setSSOChecking] = useState(enableSSO);
37
47
  const [redirecting, setRedirecting] = useState(false);
48
+ const [notice, setNotice] = useState('');
49
+ // Read via window.location rather than useSearchParams(): the latter forces
50
+ // the consuming app to wrap this component in a Suspense boundary during
51
+ // static prerender, and the login pages render it bare. Runs before the SSO
52
+ // effect below, which strips its own params from the URL.
53
+ useEffect(() => {
54
+ const reason = new URLSearchParams(window.location.search).get('reason');
55
+ if (reason && SIGN_IN_NOTICES[reason]) {
56
+ setNotice(SIGN_IN_NOTICES[reason]);
57
+ }
58
+ }, []);
38
59
  const authClient = useMemo(() => new RoboSystemsAuthClient(apiUrl), [apiUrl]);
39
60
  const { checkSSOAuthentication, handleSSOLogin } = useSSO(apiUrl);
40
61
  // Check for existing authentication or SSO login on mount
@@ -131,7 +152,7 @@ export function SignInForm({ onSuccess, onRedirect, redirectTo = '/home', classN
131
152
  if (ssoChecking || redirecting) {
132
153
  return (_jsx("div", { className: "via-primary-950 flex min-h-screen items-center justify-center bg-linear-to-br from-black to-zinc-900 p-6", children: _jsx("div", { className: "w-full max-w-md", children: _jsxs("div", { className: "text-center", children: [_jsx(LogoBadge, { animate: "loop", className: "mx-auto h-16 w-16" }), _jsx("h1", { className: "font-heading mt-4 text-center text-2xl font-semibold tracking-tight text-white", children: appName })] }) }) }));
133
154
  }
134
- return (_jsx("div", { className: "via-primary-950 flex min-h-screen items-center justify-center bg-linear-to-br from-black to-zinc-900 px-4 py-12 sm:px-6 lg:px-8", children: _jsxs("div", { className: "w-full max-w-lg space-y-8", children: [_jsxs("div", { className: "text-center", children: [_jsx(LogoBadge, { animate: "once", className: "mx-auto h-14 w-14" }), _jsx("h1", { className: "font-heading mt-4 text-center text-2xl font-semibold tracking-tight text-white", children: appName }), _jsx("h2", { className: "mt-2 text-center text-xl font-semibold tracking-tight text-gray-300", children: "Sign in to your account" })] }), _jsxs("form", { className: ['mt-8 space-y-6', className].filter(Boolean).join(' '), onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "rounded-md border border-red-800 bg-red-900/50 p-4", children: _jsx("div", { className: "text-sm text-red-300", children: error }) })), _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "email", className: "sr-only", children: "Email address" }), _jsx("input", { id: "email", name: "email", type: "email", autoComplete: "email", required: true, value: formData.email, onChange: handleInputChange, className: "focus:ring-primary-500 relative block w-full rounded-md border-0 bg-gray-800 px-5 py-4 text-base leading-7 text-white ring-1 ring-gray-600 ring-inset placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset", placeholder: "Email address", disabled: loading || redirecting })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "password", className: "sr-only", children: "Password" }), _jsx("input", { id: "password", name: "password", type: "password", autoComplete: "current-password", required: true, value: formData.password, onChange: handleInputChange, className: "focus:ring-primary-500 relative block w-full rounded-md border-0 bg-gray-800 px-5 py-4 text-base leading-7 text-white ring-1 ring-gray-600 ring-inset placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset", placeholder: "Password", disabled: loading || redirecting })] })] }), _jsx("div", { children: _jsxs("button", { type: "submit", disabled: loading || redirecting, className: "group bg-primary-600 hover:bg-primary-700 focus-visible:outline-primary-500 relative flex w-full justify-center rounded-md px-4 py-3 text-sm font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid disabled:cursor-not-allowed disabled:opacity-50", children: [loading && _jsx(Spinner, { size: "sm", className: "mr-2 text-white" }), redirecting
155
+ return (_jsx("div", { className: "via-primary-950 flex min-h-screen items-center justify-center bg-linear-to-br from-black to-zinc-900 px-4 py-12 sm:px-6 lg:px-8", children: _jsxs("div", { className: "w-full max-w-lg space-y-8", children: [_jsxs("div", { className: "text-center", children: [_jsx(LogoBadge, { animate: "once", className: "mx-auto h-14 w-14" }), _jsx("h1", { className: "font-heading mt-4 text-center text-2xl font-semibold tracking-tight text-white", children: appName }), _jsx("h2", { className: "mt-2 text-center text-xl font-semibold tracking-tight text-gray-300", children: "Sign in to your account" })] }), _jsxs("form", { className: ['mt-8 space-y-6', className].filter(Boolean).join(' '), onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "rounded-md border border-red-800 bg-red-900/50 p-4", children: _jsx("div", { className: "text-sm text-red-300", children: error }) })), notice && !error && (_jsx("div", { className: "border-primary-800 bg-primary-900/50 rounded-md border p-4", children: _jsx("div", { className: "text-primary-200 text-sm", children: notice }) })), _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "email", className: "sr-only", children: "Email address" }), _jsx("input", { id: "email", name: "email", type: "email", autoComplete: "email", required: true, value: formData.email, onChange: handleInputChange, className: "focus:ring-primary-500 relative block w-full rounded-md border-0 bg-gray-800 px-5 py-4 text-base leading-7 text-white ring-1 ring-gray-600 ring-inset placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset", placeholder: "Email address", disabled: loading || redirecting })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "password", className: "sr-only", children: "Password" }), _jsx("input", { id: "password", name: "password", type: "password", autoComplete: "current-password", required: true, value: formData.password, onChange: handleInputChange, className: "focus:ring-primary-500 relative block w-full rounded-md border-0 bg-gray-800 px-5 py-4 text-base leading-7 text-white ring-1 ring-gray-600 ring-inset placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset", placeholder: "Password", disabled: loading || redirecting })] })] }), _jsx("div", { children: _jsxs("button", { type: "submit", disabled: loading || redirecting, className: "group bg-primary-600 hover:bg-primary-700 focus-visible:outline-primary-500 relative flex w-full justify-center rounded-md px-4 py-3 text-sm font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid disabled:cursor-not-allowed disabled:opacity-50", children: [loading && _jsx(Spinner, { size: "sm", className: "mr-2 text-white" }), redirecting
135
156
  ? 'Redirecting...'
136
157
  : loading
137
158
  ? 'Signing in...'
@@ -1,6 +1,6 @@
1
1
  export { AppSwitcher } from './AppSwitcher';
2
2
  export { AuthGuard } from './AuthGuard';
3
- export { AuthProvider, useAuth } from './AuthProvider';
3
+ export { AuthProvider, useAuth, useOptionalAuth } from './AuthProvider';
4
4
  export { SignInForm } from './SignInForm';
5
5
  export { SignUpForm } from './SignUpForm';
6
6
  export { TurnstileWidget, useTurnstile } from './TurnstileWidget';
@@ -1,6 +1,6 @@
1
1
  export { AppSwitcher } from './AppSwitcher';
2
2
  export { AuthGuard } from './AuthGuard';
3
- export { AuthProvider, useAuth } from './AuthProvider';
3
+ export { AuthProvider, useAuth, useOptionalAuth } from './AuthProvider';
4
4
  export { SignInForm } from './SignInForm';
5
5
  export { SignUpForm } from './SignUpForm';
6
6
  export { TurnstileWidget, useTurnstile } from './TurnstileWidget';
@@ -71,7 +71,7 @@ export interface AuthContextType {
71
71
  };
72
72
  login: (email: string, password: string) => Promise<AuthUser>;
73
73
  register: (email: string, password: string, name?: string) => Promise<AuthUser>;
74
- logout: () => Promise<void>;
74
+ logout: (reason?: string) => Promise<void>;
75
75
  refreshUser: () => Promise<AuthUser | null>;
76
76
  refreshSession: (force?: boolean) => Promise<void>;
77
77
  forgotPassword: (email: string) => Promise<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robosystems/core",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Shared RoboSystems frontend core library (auth, contexts, task monitoring, UI components) for the RoboSystems Next.js apps",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -4,6 +4,8 @@ import * as SDK from '@robosystems/client';
4
4
  import { Button } from 'flowbite-react';
5
5
  import { useCallback, useEffect, useRef, useState } from 'react';
6
6
  import { HiLockClosed } from 'react-icons/hi';
7
+ import { useOptionalAuth } from '../../auth-components/AuthProvider';
8
+ import { ConfirmModal } from '../ConfirmModal';
7
9
  import { Spinner } from '../Spinner';
8
10
  import { SettingsCard } from '../forms/SettingsCard';
9
11
  import { SettingsFormField } from '../forms/SettingsFormField';
@@ -15,7 +17,10 @@ export const PasswordInformationCard = ({ theme, onUpdate = undefined, onSuccess
15
17
  const [newPassword, setNewPassword] = useState('');
16
18
  const [passwordStrength, setPasswordStrength] = useState(null);
17
19
  const [checkingPassword, setCheckingPassword] = useState(false);
20
+ const [pendingUpdate, setPendingUpdate] = useState(null);
18
21
  const debounceRef = useRef(null);
22
+ const formRef = useRef(null);
23
+ const auth = useOptionalAuth();
19
24
  const checkPassword = useCallback(async (password) => {
20
25
  if (password.length < 4) {
21
26
  setPasswordStrength(null);
@@ -57,8 +62,7 @@ export const PasswordInformationCard = ({ theme, onUpdate = undefined, onSuccess
57
62
  clearTimeout(debounceRef.current);
58
63
  };
59
64
  }, [newPassword, checkPassword]);
60
- const handleSubmit = async (event) => {
61
- var _a, _b, _c;
65
+ const handleSubmit = (event) => {
62
66
  event.preventDefault();
63
67
  setError(null);
64
68
  setSuccess(false);
@@ -95,18 +99,25 @@ export const PasswordInformationCard = ({ theme, onUpdate = undefined, onSuccess
95
99
  }
96
100
  return;
97
101
  }
102
+ // A password change ends every session, this one included, so take an
103
+ // explicit confirmation before doing something the user can't walk back.
104
+ setPendingUpdate(updateData);
105
+ };
106
+ const handleConfirm = async () => {
107
+ var _a, _b, _c, _d;
108
+ if (!pendingUpdate)
109
+ return;
98
110
  setIsLoading(true);
99
- const form = event.currentTarget;
100
111
  try {
101
112
  if (onUpdate) {
102
- await onUpdate(updateData);
113
+ await onUpdate(pendingUpdate);
103
114
  }
104
115
  else {
105
116
  const response = await SDK.updateUserPassword({
106
117
  body: {
107
- current_password: updateData.currentPassword,
108
- new_password: updateData.newPassword,
109
- confirm_password: updateData.confirmPassword,
118
+ current_password: pendingUpdate.currentPassword,
119
+ new_password: pendingUpdate.newPassword,
120
+ confirm_password: pendingUpdate.confirmPassword,
110
121
  },
111
122
  });
112
123
  if (response.error) {
@@ -116,14 +127,28 @@ export const PasswordInformationCard = ({ theme, onUpdate = undefined, onSuccess
116
127
  throw new Error(typeof detail === 'string' ? detail : 'Failed to update password.');
117
128
  }
118
129
  }
130
+ setPendingUpdate(null);
119
131
  if (onSuccess) {
120
132
  onSuccess('Password updated successfully.');
121
133
  }
122
134
  else {
123
135
  setSuccess(true);
124
136
  }
137
+ // The server invalidates every session on a password change, so the token
138
+ // this app holds is already dead. Sign out deliberately instead of
139
+ // letting it fail on the next request as an unexplained error.
140
+ if (auth) {
141
+ try {
142
+ await auth.logout('password_changed');
143
+ return; // navigating away — leave the card as-is
144
+ }
145
+ catch (_e) {
146
+ // The password did change; only the redirect failed. Fall through so
147
+ // the card resets rather than sitting on a spinner.
148
+ }
149
+ }
125
150
  // Clear form fields and strength state
126
- form.reset();
151
+ (_d = formRef.current) === null || _d === void 0 ? void 0 : _d.reset();
127
152
  setNewPassword('');
128
153
  setPasswordStrength(null);
129
154
  setTimeout(() => {
@@ -141,6 +166,7 @@ export const PasswordInformationCard = ({ theme, onUpdate = undefined, onSuccess
141
166
  else {
142
167
  setError(msg);
143
168
  }
169
+ setPendingUpdate(null);
144
170
  setIsLoading(false);
145
171
  }
146
172
  };
@@ -162,6 +188,6 @@ export const PasswordInformationCard = ({ theme, onUpdate = undefined, onSuccess
162
188
  : passwordStrength.score < 80
163
189
  ? 'text-primary-300'
164
190
  : 'text-green-400';
165
- return (_jsxs(SettingsCard, { title: "Password information", description: "Update your password to keep your account secure", icon: HiLockClosed, theme: theme === null || theme === void 0 ? void 0 : theme.card, className: className, children: [_jsxs("div", { className: "space-y-4", children: [error && (_jsx(StatusAlert, { type: "error", message: error, theme: theme === null || theme === void 0 ? void 0 : theme.alert })), success && (_jsx(StatusAlert, { type: "success", message: "Password updated successfully.", theme: theme === null || theme === void 0 ? void 0 : theme.alert }))] }), _jsx("form", { onSubmit: handleSubmit, children: _jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "grid gap-4 md:grid-cols-2", children: _jsx(SettingsFormField, { id: "currentPassword", label: "Current password", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", theme: theme }) }), _jsxs("div", { className: "grid items-start gap-4 md:grid-cols-2", children: [_jsxs("div", { children: [_jsx(SettingsFormField, { id: "newPassword", label: "New password", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", theme: theme, onChange: (e) => setNewPassword(e.target.value) }), _jsx("div", { className: "mt-2 min-h-[2.75rem]", children: passwordStrength && (_jsxs("div", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "h-1.5 flex-1 overflow-hidden rounded-full bg-gray-700", children: _jsx("div", { className: `h-full rounded-full transition-all duration-300 ${strengthBarColor}`, style: { width: `${passwordStrength.score}%` } }) }), _jsx("span", { className: `text-xs font-medium capitalize ${strengthTextColor}`, children: passwordStrength.strength.replace('-', ' ') })] }), passwordStrength.errors.length > 0 && (_jsx("p", { className: "text-xs text-red-400", children: passwordStrength.errors[0] })), passwordStrength.errors.length === 0 &&
166
- passwordStrength.suggestions.length > 0 && (_jsx("p", { className: "text-xs text-gray-400", children: passwordStrength.suggestions[0] }))] })) })] }), _jsx(SettingsFormField, { id: "confirmPassword", label: "Confirm password", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", theme: theme })] }), _jsx("div", { className: "flex justify-end pt-2", children: _jsx(Button, { theme: theme === null || theme === void 0 ? void 0 : theme.button, color: "blue", type: "submit", disabled: isLoading || checkingPassword, children: isLoading ? (_jsx(Spinner, { size: "sm", className: "text-white" })) : ('Update Password') }) })] }) })] }));
191
+ return (_jsxs(SettingsCard, { title: "Password information", description: "Update your password to keep your account secure", icon: HiLockClosed, theme: theme === null || theme === void 0 ? void 0 : theme.card, className: className, children: [_jsxs("div", { className: "space-y-4", children: [error && (_jsx(StatusAlert, { type: "error", message: error, theme: theme === null || theme === void 0 ? void 0 : theme.alert })), success && (_jsx(StatusAlert, { type: "success", message: "Password updated successfully.", theme: theme === null || theme === void 0 ? void 0 : theme.alert }))] }), _jsx("form", { ref: formRef, onSubmit: handleSubmit, children: _jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "grid gap-4 md:grid-cols-2", children: _jsx(SettingsFormField, { id: "currentPassword", label: "Current password", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", theme: theme }) }), _jsxs("div", { className: "grid items-start gap-4 md:grid-cols-2", children: [_jsxs("div", { children: [_jsx(SettingsFormField, { id: "newPassword", label: "New password", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", theme: theme, onChange: (e) => setNewPassword(e.target.value) }), _jsx("div", { className: "mt-2 min-h-[2.75rem]", children: passwordStrength && (_jsxs("div", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "h-1.5 flex-1 overflow-hidden rounded-full bg-gray-700", children: _jsx("div", { className: `h-full rounded-full transition-all duration-300 ${strengthBarColor}`, style: { width: `${passwordStrength.score}%` } }) }), _jsx("span", { className: `text-xs font-medium capitalize ${strengthTextColor}`, children: passwordStrength.strength.replace('-', ' ') })] }), passwordStrength.errors.length > 0 && (_jsx("p", { className: "text-xs text-red-400", children: passwordStrength.errors[0] })), passwordStrength.errors.length === 0 &&
192
+ passwordStrength.suggestions.length > 0 && (_jsx("p", { className: "text-xs text-gray-400", children: passwordStrength.suggestions[0] }))] })) })] }), _jsx(SettingsFormField, { id: "confirmPassword", label: "Confirm password", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022", theme: theme })] }), _jsx("div", { className: "flex justify-end pt-2", children: _jsx(Button, { theme: theme === null || theme === void 0 ? void 0 : theme.button, color: "blue", type: "submit", disabled: isLoading || checkingPassword, children: isLoading ? (_jsx(Spinner, { size: "sm", className: "text-white" })) : ('Update Password') }) })] }) }), _jsx(ConfirmModal, { show: pendingUpdate !== null, onClose: () => setPendingUpdate(null), onConfirm: handleConfirm, loading: isLoading, title: "Change password", confirmLabel: "Change password", loadingLabel: "Changing\u2026", confirmColor: "blue", confirmIcon: HiLockClosed, children: _jsx("p", { className: "text-sm text-gray-300", children: "Changing your password signs you out everywhere, including on this device. You'll need to sign in again with your new password." }) })] }));
167
193
  };