Skip to content

Commit 53e815e

Browse files
committed
feat(api,platform): add support for the operator-only API to clone users
1 parent 11244d7 commit 53e815e

23 files changed

Lines changed: 1809 additions & 35 deletions
-292 Bytes
Loading
-3 Bytes
Loading
-1 Bytes
Loading
-1 Bytes
Loading
0 Bytes
Loading

components/secutils-webui/src/pages/signin/recover_account_modal.tsx

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ import { getOryApi } from '../../tools/ory';
2323

2424
export interface RecoverAccountModalProps {
2525
email?: string;
26+
/**
27+
* Optional pre-existing Kratos recovery flow ID. When provided (e.g. via the `/signin?recover=1&flow=...` deep-link
28+
* Kratos uses for admin-issued recovery codes), the modal jumps straight to the "enter recovery code" step instead of
29+
* asking the user to request a new code.
30+
*/
31+
flowId?: string;
2632
onClose: () => void;
2733
}
2834

@@ -39,14 +45,38 @@ async function getRecoverFlow(api: FrontendApi, flowId?: string) {
3945
return await api.createBrowserRecoveryFlow();
4046
}
4147

42-
export function RecoverAccountModal({ email, onClose }: RecoverAccountModalProps) {
48+
export function RecoverAccountModal({ email, flowId, onClose }: RecoverAccountModalProps) {
4349
const { addToast, uiState, refreshUiState } = useAppContext();
4450
const navigate = useNavigate();
4551

4652
const [userEmail, setUserEmail] = useState<string>(email ?? '');
4753
const [recoveryCode, setRecoveryCode] = useState<string>('');
4854

4955
const [accountRecoveryStatus, setAccountRecoveryStatus] = useState<AsyncData<undefined, RecoveryFlow> | null>(null);
56+
57+
// When a Kratos recovery flow ID is supplied via deep-link, hydrate it on mount so the
58+
// submit step is wired up to the same flow Kratos already issued the code for.
59+
useEffect(() => {
60+
if (!flowId) {
61+
return;
62+
}
63+
64+
let cancelled = false;
65+
getOryApi()
66+
.then(async (api) => {
67+
const flow = await getRecoverFlow(api, flowId);
68+
if (cancelled) {
69+
return;
70+
}
71+
setAccountRecoveryStatus({ status: 'succeeded', data: undefined, state: flow });
72+
})
73+
.catch((err: unknown) => {
74+
console.error('Failed to load recovery flow from deep-link.', err);
75+
});
76+
return () => {
77+
cancelled = true;
78+
};
79+
}, [flowId]);
5080
const onSendRecoveryCode: MouseEventHandler<HTMLButtonElement> = (e) => {
5181
e.preventDefault();
5282

@@ -156,6 +186,10 @@ export function RecoverAccountModal({ email, onClose }: RecoverAccountModalProps
156186
}, [uiState, navigate]);
157187

158188
const awaitingRecoveryCode = !!accountRecoveryStatus?.state;
189+
// True only when the modal was opened by following a Kratos-issued recovery link and the referenced flow has been
190+
// successfully hydrated. In this mode the email step is bypassed because the flow is already bound to a specific
191+
// identity server-side.
192+
const isDeepLinkMode = !!flowId && accountRecoveryStatus?.state?.id === flowId;
159193
return (
160194
<EuiModal onClose={onClose}>
161195
<EuiModalHeader>
@@ -167,23 +201,32 @@ export function RecoverAccountModal({ email, onClose }: RecoverAccountModalProps
167201
</EuiModalHeader>
168202
<EuiModalBody>
169203
<EuiForm id="account-recovery-form" component="form">
170-
<EuiFormRow label="Email">
171-
<EuiFieldText
172-
value={userEmail}
173-
autoComplete={'email'}
174-
type={'email'}
175-
required
176-
disabled={awaitingRecoveryCode}
177-
onChange={(e) => setUserEmail(e.target.value)}
178-
/>
179-
</EuiFormRow>
204+
{isDeepLinkMode ? null : (
205+
<EuiFormRow label="Email">
206+
<EuiFieldText
207+
value={userEmail}
208+
autoComplete={'email'}
209+
type={'email'}
210+
required
211+
disabled={awaitingRecoveryCode}
212+
onChange={(e) => setUserEmail(e.target.value)}
213+
/>
214+
</EuiFormRow>
215+
)}
180216
{awaitingRecoveryCode ? (
181-
<EuiFormRow label={'Recovery code'}>
217+
<EuiFormRow
218+
label={'Recovery code'}
219+
helpText={isDeepLinkMode ? 'Enter the code that was issued for your account.' : undefined}
220+
>
182221
<EuiFieldText
183222
value={recoveryCode}
184223
autoComplete={'off'}
185224
type={'text'}
186-
append={<EuiButtonIcon iconType="refresh" onClick={onSendRecoveryCode} aria-label="Resend code" />}
225+
append={
226+
isDeepLinkMode ? undefined : (
227+
<EuiButtonIcon iconType="refresh" onClick={onSendRecoveryCode} aria-label="Resend code" />
228+
)
229+
}
187230
onChange={(e) => setRecoveryCode(e.target.value)}
188231
/>
189232
</EuiFormRow>
@@ -199,7 +242,7 @@ export function RecoverAccountModal({ email, onClose }: RecoverAccountModalProps
199242
type="submit"
200243
form="account-recovery-form"
201244
fill
202-
disabled={accountRecoveryStatus?.status === 'pending' || !userEmail?.trim() || !recoveryCode?.trim()}
245+
disabled={accountRecoveryStatus?.status === 'pending' || !recoveryCode?.trim()}
203246
onClick={onRecoverAccount}
204247
isLoading={accountRecoveryStatus?.status === 'pending'}
205248
>

components/secutils-webui/src/pages/signin/signin_page.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '@elastic/eui';
1010
import type { FrontendApi, LoginFlow, UiNodeInputAttributes } from '@ory/kratos-client-fetch';
1111
import type { ChangeEvent } from 'react';
12-
import { useCallback, useState } from 'react';
12+
import { useCallback, useEffect, useState } from 'react';
1313
import { Navigate, useNavigate, useSearchParams } from 'react-router';
1414

1515
import { RecoverAccountModal } from './recover_account_modal';
@@ -89,12 +89,34 @@ export function SigninPage() {
8989

9090
const [signinStatus, setSigninStatus] = useState<AsyncData<null, { isPasskey: boolean }> | null>(null);
9191
const [isResetPasswordModalOpen, setIsResetPasswordModalOpen] = useState(false);
92-
const onToggleResetPasswordModal = useCallback(() => {
93-
setIsResetPasswordModalOpen((isOpen) => !isOpen);
94-
}, []);
92+
93+
// Kratos lands users on `/signin?recover=1[&flow=<recovery-flow-id>]` after an admin- or user-initiated recovery
94+
// flow. Auto-open the modal with the flow ID so the user only has to enter the code.
95+
const recoverParam = searchParams.get('recover');
96+
const recoveryFlowId = searchParams.get('flow');
97+
useEffect(() => {
98+
if (recoverParam === '1') {
99+
setIsResetPasswordModalOpen(true);
100+
}
101+
}, [recoverParam]);
102+
103+
const onCloseResetPasswordModal = useCallback(() => {
104+
setIsResetPasswordModalOpen(false);
105+
// Clear the deep-link params so refreshing/navigating away no longer reopens the modal and the signin flow's own
106+
// `flow` param isn't confused with the recovery one.
107+
if (searchParams.has('recover') || (recoverParam === '1' && searchParams.has('flow'))) {
108+
searchParams.delete('recover');
109+
searchParams.delete('flow');
110+
setSearchParams(searchParams);
111+
}
112+
}, [recoverParam, searchParams, setSearchParams]);
95113

96114
const resetPasswordModal = isResetPasswordModalOpen ? (
97-
<RecoverAccountModal onClose={onToggleResetPasswordModal} email={email} />
115+
<RecoverAccountModal
116+
onClose={onCloseResetPasswordModal}
117+
email={email}
118+
flowId={recoverParam === '1' ? (recoveryFlowId ?? undefined) : undefined}
119+
/>
98120
) : null;
99121

100122
if (uiState.user) {

dev/api/security/users_clone.http

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
### Clone user by email (creates a sandbox copy + returns Kratos recovery link)
2+
POST {{host}}/api/users/_clone
3+
Accept: application/json
4+
Content-Type: application/json
5+
Authorization: {{api-credentials}}
6+
7+
{
8+
"source": { "email": "demo@secutils.dev" },
9+
"destination": { "email": "demo+clone@secutils.dev", "handle": "clone" },
10+
"includeHistory": true,
11+
"copySubscription": true,
12+
"recoveryLinkExpiresIn": "1h"
13+
}
14+
15+
### Clone user by ID (alternative selector) with explicit handle
16+
POST {{host}}/api/users/_clone
17+
Accept: application/json
18+
Content-Type: application/json
19+
Authorization: {{api-credentials}}
20+
21+
{
22+
"source": { "id": "00000000-0000-0000-0000-000000000001" },
23+
"destination": {
24+
"email": "support+u123@secutils.dev",
25+
"handle": "supportu123"
26+
}
27+
}
28+
29+
### Remove the clone by ID (sibling of POST /api/users/remove)
30+
DELETE {{host}}/api/users/{{user-id}}
31+
Accept: application/json
32+
Authorization: {{api-credentials}}

dev/docker/kratos-e2e.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ password = { enabled = true }
3131
webauthn = { enabled = true, config = { passwordless = true, rp = { display_name = "Secutils.dev", id = "localhost", origin = "http://localhost:7171" } } }
3232

3333
[selfservice.flows]
34-
recovery = { enabled = true, lifespan = "15m", use = "code", notify_unknown_recipients = false, after = { hooks = [{ hook = "revoke_active_sessions" }] } }
34+
recovery = { enabled = true, lifespan = "15m", use = "code", ui_url = "http://localhost:7171/signin?recover=1", notify_unknown_recipients = false, after = { hooks = [{ hook = "revoke_active_sessions" }] } }
3535
settings = { privileged_session_max_age = "15m" }
3636
verification = { enabled = true }
3737

dev/docker/kratos.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ password = { enabled = true }
2929
webauthn = { enabled = true, config = { passwordless = true, rp = { display_name = "Secutils.dev", id = "localhost", origin = "http://localhost:7171" } } }
3030

3131
[selfservice.flows]
32-
recovery = { enabled = true, lifespan = "15m", use = "code", notify_unknown_recipients = false, after = { hooks = [{ hook = "revoke_active_sessions" }] } }
32+
recovery = { enabled = true, lifespan = "15m", use = "code", ui_url = "http://127.0.0.1:7171/signin?recover=1", notify_unknown_recipients = false, after = { hooks = [{ hook = "revoke_active_sessions" }] } }
3333
settings = { privileged_session_max_age = "15m" }
3434
verification = { enabled = true }
3535

0 commit comments

Comments
 (0)