@robosystems/core 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auth-components/AuthGuard.js +16 -2
- package/auth-components/AuthProvider.d.ts +7 -0
- package/auth-components/AuthProvider.js +38 -9
- package/auth-components/LoginRedirector.d.ts +20 -0
- package/auth-components/LoginRedirector.js +85 -0
- package/auth-components/SignInForm.d.ts +6 -0
- package/auth-components/SignInForm.js +22 -1
- package/auth-components/index.d.ts +2 -1
- package/auth-components/index.js +2 -1
- package/auth-core/client.d.ts +9 -3
- package/auth-core/client.js +15 -3
- package/auth-core/config.d.ts +8 -0
- package/auth-core/config.js +8 -0
- package/auth-core/index.d.ts +1 -0
- package/auth-core/index.js +1 -0
- package/auth-core/login-home.d.ts +46 -0
- package/auth-core/login-home.js +61 -0
- package/auth-core/types.d.ts +9 -3
- package/index.d.ts +4 -1
- package/index.js +4 -1
- package/package.json +1 -1
- package/ui-components/settings/PasswordInformationCard.js +36 -10
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useRouter } from 'next/navigation';
|
|
4
4
|
import { useEffect } from 'react';
|
|
5
|
+
import { isSafeRelativePath } from '../auth-core/login-home';
|
|
5
6
|
import { BrandSpinner } from '../ui-components';
|
|
6
7
|
import { useAuth } from './AuthProvider';
|
|
7
8
|
export function AuthGuard({ children, redirectTo = '/login', loadingComponent, }) {
|
|
@@ -9,15 +10,28 @@ export function AuthGuard({ children, redirectTo = '/login', loadingComponent, }
|
|
|
9
10
|
const router = useRouter();
|
|
10
11
|
useEffect(() => {
|
|
11
12
|
if (!isLoading && !isAuthenticated) {
|
|
13
|
+
// Carry the intended destination so the login flow can land the user
|
|
14
|
+
// back where they were headed instead of the default landing page.
|
|
15
|
+
// A bare "/" carries no information — the default landing already
|
|
16
|
+
// covers it — so it is not forwarded.
|
|
17
|
+
let target = redirectTo;
|
|
18
|
+
if (typeof window !== 'undefined') {
|
|
19
|
+
const destination = window.location.pathname + window.location.search;
|
|
20
|
+
if (destination !== '/' &&
|
|
21
|
+
destination !== redirectTo &&
|
|
22
|
+
isSafeRelativePath(destination)) {
|
|
23
|
+
target = `${redirectTo}?return_to=${encodeURIComponent(destination)}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
12
26
|
try {
|
|
13
|
-
router.push(
|
|
27
|
+
router.push(target);
|
|
14
28
|
}
|
|
15
29
|
catch (error) {
|
|
16
30
|
if (process.env.NODE_ENV === 'development') {
|
|
17
31
|
// Fallback to window.location if router.push fails
|
|
18
32
|
console.error('Router push failed, using window.location fallback:', error);
|
|
19
33
|
}
|
|
20
|
-
window.location.href =
|
|
34
|
+
window.location.href = target;
|
|
21
35
|
}
|
|
22
36
|
}
|
|
23
37
|
}, [isAuthenticated, isLoading, router, redirectTo]);
|
|
@@ -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 {};
|
|
@@ -5,7 +5,9 @@ import { clearEntitySelection } from '../actions/entity-actions';
|
|
|
5
5
|
import { clearGraphSelection } from '../actions/graph-actions';
|
|
6
6
|
import { performLogoutCleanup } from '../auth-core/cleanup';
|
|
7
7
|
import { RoboSystemsAuthClient } from '../auth-core/client';
|
|
8
|
+
import { CURRENT_APP, isLoginHome } from '../auth-core/config';
|
|
8
9
|
import { useTokenExpiryHandler } from '../auth-core/hooks';
|
|
10
|
+
import { buildLoginHomeUrl, buildReturnTo } from '../auth-core/login-home';
|
|
9
11
|
import { getTimeUntilExpiry, getTokenStatus } from '../auth-core/token-storage';
|
|
10
12
|
// Configuration constants
|
|
11
13
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
@@ -108,7 +110,7 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
108
110
|
setIsLoading(false);
|
|
109
111
|
}
|
|
110
112
|
}, [authClient]);
|
|
111
|
-
const logout = useCallback(async (reason) => {
|
|
113
|
+
const logout = useCallback(async (reason, options) => {
|
|
112
114
|
try {
|
|
113
115
|
await authClient.logout();
|
|
114
116
|
}
|
|
@@ -141,11 +143,29 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
141
143
|
// authenticated tree tears down instead of AuthGuard flashing a blank
|
|
142
144
|
// screen once user state clears. A forced logout (reason set, e.g.
|
|
143
145
|
// session_expired) goes to the login page so it can explain why the
|
|
144
|
-
// session ended and let the user sign back in
|
|
145
|
-
//
|
|
146
|
-
//
|
|
146
|
+
// session ended and let the user sign back in. A manual logout on a
|
|
147
|
+
// product app chains through the login home's /logout so the anchor
|
|
148
|
+
// session dies too — otherwise the next visit silently re-bridges and
|
|
149
|
+
// "log out" appears not to work. On the login home itself (and in
|
|
150
|
+
// single-app deployments, where the app is its own login home) manual
|
|
151
|
+
// logout goes to the public homepage. `options.redirectTo` overrides
|
|
152
|
+
// the destination — used by the login home's /logout route to land the
|
|
153
|
+
// user back on the app they came from.
|
|
147
154
|
if (typeof window !== 'undefined') {
|
|
148
|
-
|
|
155
|
+
if (options === null || options === void 0 ? void 0 : options.redirectTo) {
|
|
156
|
+
window.location.href = options.redirectTo;
|
|
157
|
+
}
|
|
158
|
+
else if (reason) {
|
|
159
|
+
window.location.href = `/login?reason=${reason}`;
|
|
160
|
+
}
|
|
161
|
+
else if (!isLoginHome()) {
|
|
162
|
+
window.location.href = buildLoginHomeUrl('logout', {
|
|
163
|
+
returnTo: buildReturnTo(CURRENT_APP),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
window.location.href = '/';
|
|
168
|
+
}
|
|
149
169
|
}
|
|
150
170
|
}
|
|
151
171
|
}, [authClient]);
|
|
@@ -494,8 +514,8 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
494
514
|
throw new Error(response.message || 'Registration failed');
|
|
495
515
|
}
|
|
496
516
|
};
|
|
497
|
-
const forgotPassword = async (email) => {
|
|
498
|
-
return authClient.forgotPassword(email);
|
|
517
|
+
const forgotPassword = async (email, options) => {
|
|
518
|
+
return authClient.forgotPassword(email, options);
|
|
499
519
|
};
|
|
500
520
|
const resetPassword = async (token, newPassword) => {
|
|
501
521
|
const result = await authClient.resetPassword(token, newPassword);
|
|
@@ -534,8 +554,8 @@ export function AuthProvider({ children, apiUrl = process.env.NEXT_PUBLIC_ROBOSY
|
|
|
534
554
|
}
|
|
535
555
|
return result;
|
|
536
556
|
};
|
|
537
|
-
const resendVerificationEmail = async (email) => {
|
|
538
|
-
return authClient.resendVerificationEmail(email);
|
|
557
|
+
const resendVerificationEmail = async (email, options) => {
|
|
558
|
+
return authClient.resendVerificationEmail(email, options);
|
|
539
559
|
};
|
|
540
560
|
const value = {
|
|
541
561
|
user,
|
|
@@ -562,3 +582,12 @@ export function useAuth() {
|
|
|
562
582
|
}
|
|
563
583
|
return context;
|
|
564
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Like `useAuth`, but returns `null` outside an `AuthProvider` instead of
|
|
587
|
+
* throwing. For components that integrate with auth when it is present but must
|
|
588
|
+
* still render without it — design-system previews and isolated component tests
|
|
589
|
+
* mount components with no provider around them.
|
|
590
|
+
*/
|
|
591
|
+
export function useOptionalAuth() {
|
|
592
|
+
return useContext(AuthContext);
|
|
593
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface LoginRedirectorProps {
|
|
2
|
+
/** Which auth surface this page stands in for. */
|
|
3
|
+
mode?: 'login' | 'register';
|
|
4
|
+
apiUrl: string;
|
|
5
|
+
/** Where a completed bridge without a returnUrl lands. */
|
|
6
|
+
homePath?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Product-app stand-in for the login/register pages under centralized login.
|
|
10
|
+
*
|
|
11
|
+
* Two jobs:
|
|
12
|
+
* - `?session_id=` present: consume the SSO bridge handoff exactly as the
|
|
13
|
+
* full login page did. On failure it renders an error with a manual link
|
|
14
|
+
* to the login home — never an automatic redirect, which with a live
|
|
15
|
+
* anchor session would loop (login home re-bridges → fails → back again).
|
|
16
|
+
* - otherwise: redirect to the login home, upgrading a local `?return_to=`
|
|
17
|
+
* path to app-qualified form and forwarding foreign query params
|
|
18
|
+
* (e.g. `invite`) so mailed links keep working.
|
|
19
|
+
*/
|
|
20
|
+
export declare function LoginRedirector({ mode, apiUrl, homePath, }: LoginRedirectorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { CURRENT_APP, isLoginHome } from '../auth-core/config';
|
|
5
|
+
import { buildLoginHomeUrl, buildReturnTo, isSafeRelativePath, } from '../auth-core/login-home';
|
|
6
|
+
import { useSSO } from '../auth-core/sso';
|
|
7
|
+
import { BrandSpinner } from '../ui-components';
|
|
8
|
+
/**
|
|
9
|
+
* Product-app stand-in for the login/register pages under centralized login.
|
|
10
|
+
*
|
|
11
|
+
* Two jobs:
|
|
12
|
+
* - `?session_id=` present: consume the SSO bridge handoff exactly as the
|
|
13
|
+
* full login page did. On failure it renders an error with a manual link
|
|
14
|
+
* to the login home — never an automatic redirect, which with a live
|
|
15
|
+
* anchor session would loop (login home re-bridges → fails → back again).
|
|
16
|
+
* - otherwise: redirect to the login home, upgrading a local `?return_to=`
|
|
17
|
+
* path to app-qualified form and forwarding foreign query params
|
|
18
|
+
* (e.g. `invite`) so mailed links keep working.
|
|
19
|
+
*/
|
|
20
|
+
export function LoginRedirector({ mode = 'login', apiUrl, homePath = '/home', }) {
|
|
21
|
+
const { handleSSOLogin } = useSSO(apiUrl);
|
|
22
|
+
const [bridgeFailed, setBridgeFailed] = useState(false);
|
|
23
|
+
const [misconfigured, setMisconfigured] = useState(false);
|
|
24
|
+
const startedRef = useRef(false);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
var _a;
|
|
27
|
+
// Guard against StrictMode double-mount: the first run consumes
|
|
28
|
+
// session_id and strips it from the URL, so a second run would
|
|
29
|
+
// misread the state as a failed bridge.
|
|
30
|
+
if (startedRef.current) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
startedRef.current = true;
|
|
34
|
+
const params = new URLSearchParams(window.location.search);
|
|
35
|
+
const sessionId = params.get('session_id');
|
|
36
|
+
if (sessionId) {
|
|
37
|
+
const returnUrl = params.get('returnUrl');
|
|
38
|
+
void handleSSOLogin().then((user) => {
|
|
39
|
+
if (user) {
|
|
40
|
+
// With a returnUrl, handleSSOLogin navigates there itself.
|
|
41
|
+
if (!returnUrl) {
|
|
42
|
+
window.location.href = homePath;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
setBridgeFailed(true);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Never self-redirect: mounted on the app that IS the login home
|
|
52
|
+
// (misconfiguration — the login home renders real auth forms, not this
|
|
53
|
+
// redirector), buildLoginHomeUrl() resolves to this same app and
|
|
54
|
+
// window.location.replace() would loop. Fail safe with a static state,
|
|
55
|
+
// mirroring AuthProvider.logout's isLoginHome() guard.
|
|
56
|
+
if (isLoginHome()) {
|
|
57
|
+
console.error('[LoginRedirector] rendered on the login home app — refusing to self-redirect. Check NEXT_PUBLIC_LOGIN_HOME_APP / the centralized-login flag.');
|
|
58
|
+
setMisconfigured(true);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const localReturnTo = params.get('return_to');
|
|
62
|
+
const path = localReturnTo && isSafeRelativePath(localReturnTo)
|
|
63
|
+
? localReturnTo
|
|
64
|
+
: undefined;
|
|
65
|
+
const reason = (_a = params.get('reason')) !== null && _a !== void 0 ? _a : undefined;
|
|
66
|
+
// replace() keeps the redirector page out of history so Back returns
|
|
67
|
+
// to the page the user actually came from.
|
|
68
|
+
window.location.replace(buildLoginHomeUrl(mode, {
|
|
69
|
+
returnTo: buildReturnTo(CURRENT_APP, path),
|
|
70
|
+
reason,
|
|
71
|
+
forwardParams: params,
|
|
72
|
+
}));
|
|
73
|
+
}, [handleSSOLogin, mode, homePath]);
|
|
74
|
+
if (misconfigured) {
|
|
75
|
+
return (_jsxs("div", { className: "flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center", children: [_jsx("p", { className: "text-lg font-semibold text-gray-900 dark:text-white", children: "Sign-in is temporarily unavailable" }), _jsx("p", { className: "max-w-md text-sm text-gray-600 dark:text-gray-400", children: "This page is misconfigured. Please contact support if the problem persists." })] }));
|
|
76
|
+
}
|
|
77
|
+
if (bridgeFailed) {
|
|
78
|
+
const retryUrl = buildLoginHomeUrl(mode, {
|
|
79
|
+
returnTo: buildReturnTo(CURRENT_APP),
|
|
80
|
+
reason: 'bridge_failed',
|
|
81
|
+
});
|
|
82
|
+
return (_jsxs("div", { className: "flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center", children: [_jsx("p", { className: "text-lg font-semibold text-gray-900 dark:text-white", children: "Sign-in could not be completed" }), _jsx("p", { className: "max-w-md text-sm text-gray-600 dark:text-gray-400", children: "The sign-in handoff expired or was invalid. This can happen when a sign-in link is reused or takes too long to load." }), _jsx("a", { href: retryUrl, className: "rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700", children: "Continue to sign in" })] }));
|
|
83
|
+
}
|
|
84
|
+
return _jsx(BrandSpinner, { size: "xl", fullScreen: true });
|
|
85
|
+
}
|
|
@@ -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,7 @@
|
|
|
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
|
+
export { LoginRedirector } from './LoginRedirector';
|
|
4
5
|
export { SignInForm } from './SignInForm';
|
|
5
6
|
export { SignUpForm } from './SignUpForm';
|
|
6
7
|
export { TurnstileWidget, useTurnstile } from './TurnstileWidget';
|
package/auth-components/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
export { LoginRedirector } from './LoginRedirector';
|
|
4
5
|
export { SignInForm } from './SignInForm';
|
|
5
6
|
export { SignUpForm } from './SignUpForm';
|
|
6
7
|
export { TurnstileWidget, useTurnstile } from './TurnstileWidget';
|
package/auth-core/client.d.ts
CHANGED
|
@@ -35,7 +35,9 @@ export declare class RoboSystemsAuthClient {
|
|
|
35
35
|
*/
|
|
36
36
|
private syncClientConfigToken;
|
|
37
37
|
login(email: string, password: string): Promise<AuthResponse>;
|
|
38
|
-
register(email: string, password: string, name?: string, captchaToken?: string, inviteToken?: string
|
|
38
|
+
register(email: string, password: string, name?: string, captchaToken?: string, inviteToken?: string, options?: {
|
|
39
|
+
appSource?: string;
|
|
40
|
+
}): Promise<AuthResponse>;
|
|
39
41
|
logout(): Promise<void>;
|
|
40
42
|
getCurrentUser(): Promise<AuthUser>;
|
|
41
43
|
private performAuthCheck;
|
|
@@ -57,7 +59,9 @@ export declare class RoboSystemsAuthClient {
|
|
|
57
59
|
/**
|
|
58
60
|
* Send password reset email
|
|
59
61
|
*/
|
|
60
|
-
forgotPassword(email: string
|
|
62
|
+
forgotPassword(email: string, options?: {
|
|
63
|
+
appSource?: string;
|
|
64
|
+
}): Promise<{
|
|
61
65
|
success: boolean;
|
|
62
66
|
message?: string;
|
|
63
67
|
}>;
|
|
@@ -87,7 +91,9 @@ export declare class RoboSystemsAuthClient {
|
|
|
87
91
|
/**
|
|
88
92
|
* Resend verification email
|
|
89
93
|
*/
|
|
90
|
-
resendVerificationEmail(email: string
|
|
94
|
+
resendVerificationEmail(email: string, options?: {
|
|
95
|
+
appSource?: string;
|
|
96
|
+
}): Promise<{
|
|
91
97
|
success: boolean;
|
|
92
98
|
message?: string;
|
|
93
99
|
}>;
|
package/auth-core/client.js
CHANGED
|
@@ -18,6 +18,15 @@ const setSDKClientConfig = (_a = sdkClientsModule.setSDKClientConfig) !== null &
|
|
|
18
18
|
// Configuration constants
|
|
19
19
|
const CACHE_TTL_MS = 30 * 1000; // 30 seconds - optimized for performance
|
|
20
20
|
const ERROR_CACHE_TTL_MS = 5 * 1000; // 5 seconds - allow reasonable retry delay
|
|
21
|
+
/**
|
|
22
|
+
* Identifies which product app an auth request originates from, for email
|
|
23
|
+
* branding. Under centralized login every interactive auth request comes
|
|
24
|
+
* from the login home, so Referer-based detection on the backend collapses
|
|
25
|
+
* to one app — the originating app (from `return_to`) rides this header
|
|
26
|
+
* instead.
|
|
27
|
+
*/
|
|
28
|
+
const APP_SOURCE_HEADER = 'X-App-Source';
|
|
29
|
+
const appSourceHeaders = (appSource) => appSource ? { [APP_SOURCE_HEADER]: appSource } : undefined;
|
|
21
30
|
// Custom error class for token expiry
|
|
22
31
|
export class TokenExpiredError extends Error {
|
|
23
32
|
constructor(message = 'Token expired') {
|
|
@@ -200,7 +209,7 @@ export class RoboSystemsAuthClient {
|
|
|
200
209
|
refresh_threshold: sdkResponse.refresh_threshold,
|
|
201
210
|
};
|
|
202
211
|
}
|
|
203
|
-
async register(email, password, name, captchaToken, inviteToken) {
|
|
212
|
+
async register(email, password, name, captchaToken, inviteToken, options) {
|
|
204
213
|
const response = await registerUser({
|
|
205
214
|
client: this.client,
|
|
206
215
|
body: {
|
|
@@ -210,6 +219,7 @@ export class RoboSystemsAuthClient {
|
|
|
210
219
|
captcha_token: captchaToken || undefined,
|
|
211
220
|
invite_token: inviteToken || undefined,
|
|
212
221
|
},
|
|
222
|
+
headers: appSourceHeaders(options === null || options === void 0 ? void 0 : options.appSource),
|
|
213
223
|
});
|
|
214
224
|
// Check for error responses (4xx/5xx)
|
|
215
225
|
if (response.error) {
|
|
@@ -490,11 +500,12 @@ export class RoboSystemsAuthClient {
|
|
|
490
500
|
/**
|
|
491
501
|
* Send password reset email
|
|
492
502
|
*/
|
|
493
|
-
async forgotPassword(email) {
|
|
503
|
+
async forgotPassword(email, options) {
|
|
494
504
|
try {
|
|
495
505
|
await forgotPassword({
|
|
496
506
|
client: this.client,
|
|
497
507
|
body: { email },
|
|
508
|
+
headers: appSourceHeaders(options === null || options === void 0 ? void 0 : options.appSource),
|
|
498
509
|
});
|
|
499
510
|
return {
|
|
500
511
|
success: true,
|
|
@@ -616,11 +627,12 @@ export class RoboSystemsAuthClient {
|
|
|
616
627
|
/**
|
|
617
628
|
* Resend verification email
|
|
618
629
|
*/
|
|
619
|
-
async resendVerificationEmail(email) {
|
|
630
|
+
async resendVerificationEmail(email, options) {
|
|
620
631
|
try {
|
|
621
632
|
await resendVerificationEmail({
|
|
622
633
|
client: this.client,
|
|
623
634
|
body: { email },
|
|
635
|
+
headers: appSourceHeaders(options === null || options === void 0 ? void 0 : options.appSource),
|
|
624
636
|
});
|
|
625
637
|
return {
|
|
626
638
|
success: true,
|
package/auth-core/config.d.ts
CHANGED
|
@@ -17,3 +17,11 @@ export declare const BRAND_COLORS: Record<AppName, string>;
|
|
|
17
17
|
export declare const BRAND_GRADIENTS: Record<AppName, string>;
|
|
18
18
|
export declare const APP_CONFIGS: Record<string, AppConfig>;
|
|
19
19
|
export declare const getAppConfig: (appName: string) => AppConfig;
|
|
20
|
+
/**
|
|
21
|
+
* The app hosting the interactive auth surface ("login home"). Product apps
|
|
22
|
+
* redirect login/register there; single-app deployments designate their own
|
|
23
|
+
* app key so the architecture degrades to local auth by config.
|
|
24
|
+
*/
|
|
25
|
+
export declare const LOGIN_HOME_APP: AppName;
|
|
26
|
+
export declare const getLoginHomeUrl: () => string;
|
|
27
|
+
export declare const isLoginHome: () => boolean;
|
package/auth-core/config.js
CHANGED
|
@@ -54,3 +54,11 @@ export const APP_CONFIGS = {
|
|
|
54
54
|
export const getAppConfig = (appName) => {
|
|
55
55
|
return APP_CONFIGS[appName] || APP_CONFIGS.robosystems;
|
|
56
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* The app hosting the interactive auth surface ("login home"). Product apps
|
|
59
|
+
* redirect login/register there; single-app deployments designate their own
|
|
60
|
+
* app key so the architecture degrades to local auth by config.
|
|
61
|
+
*/
|
|
62
|
+
export const LOGIN_HOME_APP = process.env.NEXT_PUBLIC_LOGIN_HOME_APP || 'robosystems';
|
|
63
|
+
export const getLoginHomeUrl = () => getAppConfig(LOGIN_HOME_APP).url;
|
|
64
|
+
export const isLoginHome = () => CURRENT_APP === LOGIN_HOME_APP;
|
package/auth-core/index.d.ts
CHANGED
package/auth-core/index.js
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the centralized login home.
|
|
3
|
+
*
|
|
4
|
+
* Interactive auth (login/register/verify/reset) renders on one designated
|
|
5
|
+
* app — the "login home" — and product apps redirect to it carrying a
|
|
6
|
+
* `return_to` value so the user is bridged back after authenticating.
|
|
7
|
+
*
|
|
8
|
+
* `return_to` grammar: `app` or `app:/path`. The app segment is validated
|
|
9
|
+
* against APP_CONFIGS; the path must be a same-app relative path. Never a
|
|
10
|
+
* free-form URL — an open redirect on a login surface is a phishing
|
|
11
|
+
* amplifier.
|
|
12
|
+
*/
|
|
13
|
+
export interface ParsedReturnTo {
|
|
14
|
+
app: string;
|
|
15
|
+
/** Relative path on the target app, or null for its default landing. */
|
|
16
|
+
path: string | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* True when `path` is a same-app relative path. Rejects scheme-relative
|
|
20
|
+
* URLs ("//host"), absolute URLs, backslashes (browsers normalize
|
|
21
|
+
* "/\host" to "//host"), and ASCII tab/newline characters (browsers
|
|
22
|
+
* strip those before URL parsing, so "/<TAB>/host" would normalize
|
|
23
|
+
* to "//host").
|
|
24
|
+
*/
|
|
25
|
+
export declare function isSafeRelativePath(path: string): boolean;
|
|
26
|
+
/** Build a `return_to` value. An unsafe path degrades to app-only. */
|
|
27
|
+
export declare function buildReturnTo(app: string, path?: string | null): string;
|
|
28
|
+
/**
|
|
29
|
+
* Parse a raw `return_to` query value (already URL-decoded by
|
|
30
|
+
* URLSearchParams — do not decode again). Unknown apps return null;
|
|
31
|
+
* an unsafe path degrades to the app's default landing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseReturnTo(raw: string | null | undefined): ParsedReturnTo | null;
|
|
34
|
+
export interface LoginHomeUrlOptions {
|
|
35
|
+
/** `return_to` value (build with buildReturnTo). */
|
|
36
|
+
returnTo?: string;
|
|
37
|
+
/** Sign-in notice key (e.g. session_expired) rendered by the login home. */
|
|
38
|
+
reason?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Query params to carry through to the login home (e.g. `invite`).
|
|
41
|
+
* Params owned by the redirect/bridge flow are dropped.
|
|
42
|
+
*/
|
|
43
|
+
forwardParams?: URLSearchParams;
|
|
44
|
+
}
|
|
45
|
+
/** Build an absolute URL on the login home for the given auth surface. */
|
|
46
|
+
export declare function buildLoginHomeUrl(mode: 'login' | 'register' | 'logout', options?: LoginHomeUrlOptions): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { APP_CONFIGS, getLoginHomeUrl } from './config';
|
|
2
|
+
/**
|
|
3
|
+
* True when `path` is a same-app relative path. Rejects scheme-relative
|
|
4
|
+
* URLs ("//host"), absolute URLs, backslashes (browsers normalize
|
|
5
|
+
* "/\host" to "//host"), and ASCII tab/newline characters (browsers
|
|
6
|
+
* strip those before URL parsing, so "/<TAB>/host" would normalize
|
|
7
|
+
* to "//host").
|
|
8
|
+
*/
|
|
9
|
+
export function isSafeRelativePath(path) {
|
|
10
|
+
if (!path.startsWith('/') || path.startsWith('//')) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return !/[\\\t\n\r]/.test(path);
|
|
14
|
+
}
|
|
15
|
+
/** Build a `return_to` value. An unsafe path degrades to app-only. */
|
|
16
|
+
export function buildReturnTo(app, path) {
|
|
17
|
+
if (path && isSafeRelativePath(path)) {
|
|
18
|
+
return `${app}:${path}`;
|
|
19
|
+
}
|
|
20
|
+
return app;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parse a raw `return_to` query value (already URL-decoded by
|
|
24
|
+
* URLSearchParams — do not decode again). Unknown apps return null;
|
|
25
|
+
* an unsafe path degrades to the app's default landing.
|
|
26
|
+
*/
|
|
27
|
+
export function parseReturnTo(raw) {
|
|
28
|
+
if (!raw) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const separator = raw.indexOf(':');
|
|
32
|
+
const app = separator === -1 ? raw : raw.slice(0, separator);
|
|
33
|
+
const path = separator === -1 ? null : raw.slice(separator + 1);
|
|
34
|
+
if (!APP_CONFIGS[app]) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (path !== null && !isSafeRelativePath(path)) {
|
|
38
|
+
return { app, path: null };
|
|
39
|
+
}
|
|
40
|
+
return { app, path };
|
|
41
|
+
}
|
|
42
|
+
/** Params the redirect/bridge flow owns; never forwarded verbatim. */
|
|
43
|
+
const OWNED_PARAMS = new Set(['session_id', 'returnUrl', 'return_to', 'reason']);
|
|
44
|
+
/** Build an absolute URL on the login home for the given auth surface. */
|
|
45
|
+
export function buildLoginHomeUrl(mode, options = {}) {
|
|
46
|
+
const url = new URL(`/${mode}`, getLoginHomeUrl());
|
|
47
|
+
if (options.forwardParams) {
|
|
48
|
+
options.forwardParams.forEach((value, key) => {
|
|
49
|
+
if (!OWNED_PARAMS.has(key)) {
|
|
50
|
+
url.searchParams.append(key, value);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (options.returnTo) {
|
|
55
|
+
url.searchParams.set('return_to', options.returnTo);
|
|
56
|
+
}
|
|
57
|
+
if (options.reason) {
|
|
58
|
+
url.searchParams.set('reason', options.reason);
|
|
59
|
+
}
|
|
60
|
+
return url.toString();
|
|
61
|
+
}
|
package/auth-core/types.d.ts
CHANGED
|
@@ -71,10 +71,14 @@ 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: (
|
|
74
|
+
logout: (reason?: string, options?: {
|
|
75
|
+
redirectTo?: string;
|
|
76
|
+
}) => Promise<void>;
|
|
75
77
|
refreshUser: () => Promise<AuthUser | null>;
|
|
76
78
|
refreshSession: (force?: boolean) => Promise<void>;
|
|
77
|
-
forgotPassword: (email: string
|
|
79
|
+
forgotPassword: (email: string, options?: {
|
|
80
|
+
appSource?: string;
|
|
81
|
+
}) => Promise<{
|
|
78
82
|
success: boolean;
|
|
79
83
|
message?: string;
|
|
80
84
|
}>;
|
|
@@ -92,7 +96,9 @@ export interface AuthContextType {
|
|
|
92
96
|
message?: string;
|
|
93
97
|
user?: AuthUser;
|
|
94
98
|
}>;
|
|
95
|
-
resendVerificationEmail: (email: string
|
|
99
|
+
resendVerificationEmail: (email: string, options?: {
|
|
100
|
+
appSource?: string;
|
|
101
|
+
}) => Promise<{
|
|
96
102
|
success: boolean;
|
|
97
103
|
message?: string;
|
|
98
104
|
}>;
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * as AuthCore from './auth-core';
|
|
2
2
|
export { clearToken, getAuthHeader, getToken, getValidToken, handleAuthResponse, hasValidToken, storeToken, } from './auth-core/token-storage';
|
|
3
3
|
export { clearUserCookies, clearUserLocalStorage, clearUserSessionStorage, listUserData, performLogoutCleanup, resetUIState, } from './auth-core/cleanup';
|
|
4
|
-
export { AppSwitcher, AuthGuard, AuthProvider, SignInForm, SignUpForm, useAuth, } from './auth-components';
|
|
4
|
+
export { AppSwitcher, AuthGuard, AuthProvider, LoginRedirector, SignInForm, SignUpForm, useAuth, } from './auth-components';
|
|
5
5
|
export { useApiError, useMediaQuery, useToast, useUser, useUserLimits, type ToastMessage, } from './hooks';
|
|
6
6
|
export { createGraphContext, createGraphProvider, createUseGraphContext, EntityProvider, GraphContext, GraphProvider, OrgProvider, ServiceOfferingsProvider, SidebarProvider, useEntity, useGraphContext, useOrg, useServiceOfferings, useSidebarContext, type EntityContextValue, type EntityProviderProps, type GraphContextValue, type GraphProviderProps, type GraphState, type OrgContextState, type OrgContextValue, type OrgProviderProps, type ServiceOfferings, } from './contexts';
|
|
7
7
|
export { clientGraphCookie, clientSidebarCookie, createMcpConnectorUrl, entityCookie, graphCookie, sidebarCookie, type EntityCookie, type GraphCookie, type McpConnectorUrl, type SidebarCookie, } from './lib';
|
|
@@ -28,3 +28,6 @@ import { client } from '@robosystems/client';
|
|
|
28
28
|
export { client };
|
|
29
29
|
export type { APIKey, AppName, AuthContextType, AuthUser } from './auth-core';
|
|
30
30
|
export { CURRENT_APP } from './auth-core/config';
|
|
31
|
+
export { getLoginHomeUrl, isLoginHome, LOGIN_HOME_APP, } from './auth-core/config';
|
|
32
|
+
export { buildLoginHomeUrl, buildReturnTo, isSafeRelativePath, parseReturnTo, } from './auth-core/login-home';
|
|
33
|
+
export type { LoginHomeUrlOptions, ParsedReturnTo, } from './auth-core/login-home';
|
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@ export { clearToken, getAuthHeader, getToken, getValidToken, handleAuthResponse,
|
|
|
6
6
|
// Export auth cleanup utilities
|
|
7
7
|
export { clearUserCookies, clearUserLocalStorage, clearUserSessionStorage, listUserData, performLogoutCleanup, resetUIState, } from './auth-core/cleanup';
|
|
8
8
|
// Export auth-components
|
|
9
|
-
export { AppSwitcher, AuthGuard, AuthProvider, SignInForm, SignUpForm, useAuth, } from './auth-components';
|
|
9
|
+
export { AppSwitcher, AuthGuard, AuthProvider, LoginRedirector, SignInForm, SignUpForm, useAuth, } from './auth-components';
|
|
10
10
|
// Export hooks
|
|
11
11
|
export { useApiError, useMediaQuery, useToast, useUser, useUserLimits, } from './hooks';
|
|
12
12
|
// Export contexts
|
|
@@ -75,3 +75,6 @@ if (process.env.NODE_ENV !== 'test') {
|
|
|
75
75
|
export { client };
|
|
76
76
|
// App identity
|
|
77
77
|
export { CURRENT_APP } from './auth-core/config';
|
|
78
|
+
// Centralized login home
|
|
79
|
+
export { getLoginHomeUrl, isLoginHome, LOGIN_HOME_APP, } from './auth-core/config';
|
|
80
|
+
export { buildLoginHomeUrl, buildReturnTo, isSafeRelativePath, parseReturnTo, } from './auth-core/login-home';
|
package/package.json
CHANGED
|
@@ -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 =
|
|
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(
|
|
113
|
+
await onUpdate(pendingUpdate);
|
|
103
114
|
}
|
|
104
115
|
else {
|
|
105
116
|
const response = await SDK.updateUserPassword({
|
|
106
117
|
body: {
|
|
107
|
-
current_password:
|
|
108
|
-
new_password:
|
|
109
|
-
confirm_password:
|
|
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
|
-
|
|
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
|
};
|