@rootly/wizard 0.1.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/LICENSE +21 -0
- package/README.md +131 -0
- package/assets/rootly-logo-glyph-purple.png +0 -0
- package/assets/rootly-logo-glyph.png +0 -0
- package/assets/welcome-screen.png +0 -0
- package/package.json +47 -0
- package/src/actions/guided.js +87 -0
- package/src/actions/inspect.js +341 -0
- package/src/actions/integrations.js +75 -0
- package/src/actions/mcp.js +52 -0
- package/src/actions/oneshot.js +243 -0
- package/src/actions/phone.js +122 -0
- package/src/actions/registry.js +373 -0
- package/src/actions/setup.js +574 -0
- package/src/actions/testing.js +141 -0
- package/src/actions/workflow.js +47 -0
- package/src/auth.js +553 -0
- package/src/cli.js +199 -0
- package/src/detect-state.js +232 -0
- package/src/format.js +43 -0
- package/src/mcp.js +254 -0
- package/src/rootly-api.js +325 -0
- package/src/runtime.js +55 -0
- package/src/tui/components/AppShell.js +68 -0
- package/src/tui/components/Banner.js +145 -0
- package/src/tui/components/BigText.js +46 -0
- package/src/tui/components/Celebration.js +47 -0
- package/src/tui/components/KeyValueList.js +22 -0
- package/src/tui/components/MenuList.js +170 -0
- package/src/tui/components/MultiSelectList.js +181 -0
- package/src/tui/components/NoticeBox.js +56 -0
- package/src/tui/components/SlideReveal.js +52 -0
- package/src/tui/index.js +2078 -0
- package/src/tui/screens/ListScreen.js +29 -0
- package/src/tui/screens/LoadFailedScreen.js +30 -0
- package/src/tui/screens/LoadingScreen.js +32 -0
- package/src/tui/screens/MainMenuScreen.js +81 -0
- package/src/tui/screens/MultiSelectScreen.js +17 -0
- package/src/tui/screens/OneShotRunnerScreen.js +186 -0
- package/src/tui/screens/OptionScreen.js +24 -0
- package/src/tui/screens/ResultScreen.js +35 -0
- package/src/tui/screens/StatusScreen.js +70 -0
- package/src/tui/screens/TextEntryScreen.js +115 -0
- package/src/tui/screens/WelcomeScreen.js +66 -0
- package/src/tui/theme.js +69 -0
- package/src/tui-legacy-bridge.js +371 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_APP_BASE_URL = process.env.ROOTLY_APP_URL?.trim() || 'https://rootly.com';
|
|
4
|
+
|
|
5
|
+
// Vendors that are alert sources deep-link to the "new alert source" form with
|
|
6
|
+
// the source type preselected. The values are the Rails STI class names
|
|
7
|
+
// (Alerts::<X>Source) the form expects in alerts_source[sourceable_type].
|
|
8
|
+
const ALERT_SOURCE_TYPES = {
|
|
9
|
+
Datadog: 'Alerts::DatadogSource',
|
|
10
|
+
Grafana: 'Alerts::GrafanaSource',
|
|
11
|
+
Sentry: 'Alerts::SentrySource'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function webHandoffUrl(kind, appBaseUrl = DEFAULT_APP_BASE_URL) {
|
|
15
|
+
const sourceableType = ALERT_SOURCE_TYPES[kind];
|
|
16
|
+
if (sourceableType) {
|
|
17
|
+
// e.g. /account/alert-sources/new?alerts_source%5Bsourceable_type%5D=Alerts%3A%3ADatadogSource
|
|
18
|
+
return `${appBaseUrl}/account/alert-sources/new?alerts_source%5Bsourceable_type%5D=${encodeURIComponent(sourceableType)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
switch (kind) {
|
|
22
|
+
case 'Slack':
|
|
23
|
+
return `${appBaseUrl}/account/integrations/slack_accounts/landing`;
|
|
24
|
+
case 'Phone':
|
|
25
|
+
// User profile, where phone numbers and notification rules are managed.
|
|
26
|
+
return `${appBaseUrl}/account/profile`;
|
|
27
|
+
case 'PagerDuty':
|
|
28
|
+
// Not an alert source — PagerDuty connects as an escalation integration.
|
|
29
|
+
return `${appBaseUrl}/account/integrations/pagerduty_accounts/new`;
|
|
30
|
+
case 'Opsgenie':
|
|
31
|
+
// Not an alert source — Opsgenie connects as an escalation integration.
|
|
32
|
+
return `${appBaseUrl}/account/integrations/opsgenie_accounts/new`;
|
|
33
|
+
default:
|
|
34
|
+
// The unified alert sources tab — where any alert source is added.
|
|
35
|
+
return `${appBaseUrl}/account/alerts?tab=alert-sources#add-alert-sources-section`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function openUrl(url) {
|
|
40
|
+
const platform = process.platform;
|
|
41
|
+
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
let child;
|
|
44
|
+
|
|
45
|
+
if (platform === 'darwin') {
|
|
46
|
+
child = spawn('open', [url], { stdio: 'ignore' });
|
|
47
|
+
} else if (platform === 'win32') {
|
|
48
|
+
child = spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', windowsHide: true });
|
|
49
|
+
} else {
|
|
50
|
+
child = spawn('xdg-open', [url], { stdio: 'ignore' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
child.on('error', () => resolve(false));
|
|
54
|
+
child.on('spawn', () => {
|
|
55
|
+
child.unref();
|
|
56
|
+
resolve(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function startWebHandoffAction({ kind, open = false, appBaseUrl } = {}) {
|
|
62
|
+
const url = webHandoffUrl(kind, appBaseUrl || DEFAULT_APP_BASE_URL);
|
|
63
|
+
const opened = open ? await openUrl(url) : false;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
summary: `${kind} setup still uses the Rootly web flow.`,
|
|
68
|
+
data: {
|
|
69
|
+
kind,
|
|
70
|
+
url,
|
|
71
|
+
opened,
|
|
72
|
+
detectable: kind === 'Slack'
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { buildHostedMcpPreview, getMcpConfigPath, verifyHostedMcpConfig, writeHostedMcpConfig } from '../mcp.js';
|
|
2
|
+
import { getStoredToken } from '../auth.js';
|
|
3
|
+
|
|
4
|
+
export async function previewMcpSetupAction({ clients = [], auth = 'Use stored token' } = {}) {
|
|
5
|
+
return {
|
|
6
|
+
ok: true,
|
|
7
|
+
summary: 'Built MCP preview.',
|
|
8
|
+
data: {
|
|
9
|
+
clients,
|
|
10
|
+
auth,
|
|
11
|
+
preview: clients.map((client) => buildHostedMcpPreview(client, auth)),
|
|
12
|
+
paths: clients.map((client) => ({
|
|
13
|
+
client,
|
|
14
|
+
path: getMcpConfigPath(client)
|
|
15
|
+
}))
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function applyMcpSetupAction({ clients = [], auth = 'Use stored token' } = {}) {
|
|
21
|
+
const token = auth === 'Use ROOTLY_TOKEN'
|
|
22
|
+
? process.env.ROOTLY_TOKEN?.trim()
|
|
23
|
+
: await getStoredToken();
|
|
24
|
+
|
|
25
|
+
if (!token) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
code: 'NO_AUTH',
|
|
29
|
+
summary: auth === 'Use ROOTLY_TOKEN'
|
|
30
|
+
? 'ROOTLY_TOKEN was not found in the environment.'
|
|
31
|
+
: 'No stored Rootly token was found.',
|
|
32
|
+
data: null
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const results = [];
|
|
37
|
+
for (const client of clients) {
|
|
38
|
+
const { targetPath, backupPath } = await writeHostedMcpConfig(client, token);
|
|
39
|
+
await verifyHostedMcpConfig(client);
|
|
40
|
+
results.push({ client, targetPath, backupPath });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
summary: 'Configured MCP for selected clients.',
|
|
46
|
+
data: {
|
|
47
|
+
clients,
|
|
48
|
+
auth,
|
|
49
|
+
results
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { loadApiClient, extractUserId, isServiceAccount } from '../runtime.js';
|
|
2
|
+
import { getAuthSummary } from '../auth.js';
|
|
3
|
+
import {
|
|
4
|
+
addTeamMembersByIdsAction,
|
|
5
|
+
createAlertSourceAction,
|
|
6
|
+
createEscalationPolicyAction,
|
|
7
|
+
createScheduleAction,
|
|
8
|
+
createTeamAction
|
|
9
|
+
} from './setup.js';
|
|
10
|
+
import { createTestAlertAction, createTestIncidentAction } from './testing.js';
|
|
11
|
+
|
|
12
|
+
function stepError(error) {
|
|
13
|
+
return error?.message?.replace(/^Rootly API request failed for [^:]+:\s*/, '') || 'unknown error';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// OAuth sessions can't write teams/schedules (they 403/404). Classify those so
|
|
17
|
+
// the summary can explain the sign-in lacks the capability, rather than
|
|
18
|
+
// surfacing a generic failure.
|
|
19
|
+
function isPermissionFailure(error) {
|
|
20
|
+
const message = error?.message || '';
|
|
21
|
+
return /\b40[134]\b/.test(message) || /Not found or unauthorized/i.test(message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Take a brand-new account from nothing to a visible alert + incident in one
|
|
25
|
+
// call. Auto-detects the sign-in's capabilities: an API key runs the whole
|
|
26
|
+
// chain, while a browser (OAuth) session does as much as it is allowed and the
|
|
27
|
+
// result explains what it could not write.
|
|
28
|
+
//
|
|
29
|
+
// `onStep` (optional) is awaited before and after each step with
|
|
30
|
+
// { step, status: 'running' | 'ok' | 'reused' | 'blocked' | 'failed' }, so a UI
|
|
31
|
+
// can show live progress (and pace it).
|
|
32
|
+
export async function runOneShotSetupAction({
|
|
33
|
+
teamName = 'Incident Response',
|
|
34
|
+
handoffTime = '09:00',
|
|
35
|
+
memberIds = []
|
|
36
|
+
} = {}, onStep) {
|
|
37
|
+
const api = await loadApiClient();
|
|
38
|
+
const authSummary = await getAuthSummary();
|
|
39
|
+
const authMode = authSummary?.mode || 'stored-token';
|
|
40
|
+
const isOAuth = authMode === 'oauth';
|
|
41
|
+
|
|
42
|
+
const steps = [];
|
|
43
|
+
const blocked = [];
|
|
44
|
+
const emit = (step, status, extra = {}) => onStep?.({ step, status, ...extra });
|
|
45
|
+
const record = (step, status, { id = null, error = null } = {}) => {
|
|
46
|
+
steps.push({ step, status, id, error });
|
|
47
|
+
if (status === 'blocked') blocked.push(step);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Run a guarded mutating step; permission failures become 'blocked' (the
|
|
51
|
+
// chain continues) and everything else becomes 'failed'. Progress is emitted
|
|
52
|
+
// around each attempt.
|
|
53
|
+
const run = async (step, fn) => {
|
|
54
|
+
await emit(step, 'running');
|
|
55
|
+
try {
|
|
56
|
+
const res = await fn();
|
|
57
|
+
record(step, 'ok', { id: res?.data?.id ?? null });
|
|
58
|
+
await emit(step, 'ok');
|
|
59
|
+
return res;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const status = isPermissionFailure(error) ? 'blocked' : 'failed';
|
|
62
|
+
const message = stepError(error);
|
|
63
|
+
record(step, status, { error: message });
|
|
64
|
+
await emit(step, status, { error: message });
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const data = {
|
|
70
|
+
authMode,
|
|
71
|
+
team: null,
|
|
72
|
+
members: [],
|
|
73
|
+
rotation: [],
|
|
74
|
+
schedule: null,
|
|
75
|
+
escalationPolicy: null,
|
|
76
|
+
alertSource: null,
|
|
77
|
+
alert: null,
|
|
78
|
+
incident: null,
|
|
79
|
+
steps
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const currentUser = await api.getCurrentUser();
|
|
83
|
+
const currentUserId = extractUserId(currentUser);
|
|
84
|
+
const serviceAccount = isServiceAccount(currentUser);
|
|
85
|
+
|
|
86
|
+
// The humans to put on the team + rotation. When none are chosen, fall back
|
|
87
|
+
// to the current identity (even an API-key bot) so the rotation is never
|
|
88
|
+
// empty and the demo shows someone on call.
|
|
89
|
+
const chosenIds = (memberIds || []).map((id) => String(id)).filter(Boolean);
|
|
90
|
+
const rotationIds = chosenIds.length ? chosenIds : (currentUserId ? [String(currentUserId)] : []);
|
|
91
|
+
data.members = chosenIds;
|
|
92
|
+
data.rotation = rotationIds;
|
|
93
|
+
|
|
94
|
+
// 1. Team — reuse the signed-in user's existing team so a re-run doesn't pile
|
|
95
|
+
// up duplicates. A user-less API key has none, so it creates one.
|
|
96
|
+
let teamId = null;
|
|
97
|
+
const existingTeamIds = currentUser?.data?.relationships?.teams?.data?.map((team) => team.id) || [];
|
|
98
|
+
if (existingTeamIds.length) {
|
|
99
|
+
await emit('team', 'running');
|
|
100
|
+
teamId = existingTeamIds[0];
|
|
101
|
+
let reusedName = null;
|
|
102
|
+
try {
|
|
103
|
+
const teamPayload = await api.getTeam(teamId);
|
|
104
|
+
reusedName = teamPayload?.data?.attributes?.name || null;
|
|
105
|
+
} catch {
|
|
106
|
+
// best-effort; the name is only for display.
|
|
107
|
+
}
|
|
108
|
+
record('team', 'reused', { id: teamId });
|
|
109
|
+
data.team = { id: teamId, name: reusedName, reused: true };
|
|
110
|
+
await emit('team', 'reused');
|
|
111
|
+
} else {
|
|
112
|
+
const teamResult = await run('team', () =>
|
|
113
|
+
createTeamAction({ name: teamName, enableAlertsAndBroadcast: true })
|
|
114
|
+
);
|
|
115
|
+
if (teamResult) {
|
|
116
|
+
teamId = teamResult.data.id;
|
|
117
|
+
data.team = { id: teamId, name: teamResult.data.name, reused: false };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const teamLabel = data.team?.name || teamName;
|
|
122
|
+
|
|
123
|
+
// 2-4. Team-dependent scaffolding — only if a team is in place.
|
|
124
|
+
if (teamId) {
|
|
125
|
+
if (chosenIds.length) {
|
|
126
|
+
await run('members', () => addTeamMembersByIdsAction({ teamId, userIds: chosenIds }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Reuse existing resources by name so a re-run doesn't fail on "already
|
|
130
|
+
// exists" — which previously broke the paging chain (no escalation policy →
|
|
131
|
+
// the test alert reached no one).
|
|
132
|
+
const schedule = await run('schedule', () =>
|
|
133
|
+
createScheduleAction({ teamId, name: `${teamLabel} On-Call`, handoffTime, memberIds: rotationIds, reuseByName: true })
|
|
134
|
+
);
|
|
135
|
+
if (schedule) {
|
|
136
|
+
data.schedule = {
|
|
137
|
+
id: schedule.data.scheduleId,
|
|
138
|
+
name: `${teamLabel} On-Call`,
|
|
139
|
+
handoffTime,
|
|
140
|
+
rotationCreated: schedule.data.rotationCreated
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const escalation = await run('escalation-policy', () =>
|
|
145
|
+
createEscalationPolicyAction({
|
|
146
|
+
teamId,
|
|
147
|
+
name: `${teamLabel} Escalation`,
|
|
148
|
+
repeatCount: 1,
|
|
149
|
+
createDefaultPath: Boolean(data.schedule),
|
|
150
|
+
// Add a level paging the on-call schedule so a triggered alert reaches a person.
|
|
151
|
+
scheduleId: data.schedule?.id || null,
|
|
152
|
+
reuseByName: true
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
if (escalation) data.escalationPolicy = { id: escalation.data.id, name: `${teamLabel} Escalation` };
|
|
156
|
+
|
|
157
|
+
const source = await run('alert-source', () =>
|
|
158
|
+
createAlertSourceAction({ teamId, name: `${teamLabel} Webhook`, reuseByName: true })
|
|
159
|
+
);
|
|
160
|
+
if (source) {
|
|
161
|
+
data.alertSource = {
|
|
162
|
+
id: source.data.id,
|
|
163
|
+
name: `${teamLabel} Webhook`,
|
|
164
|
+
webhookEndpoint: source.data.webhookEndpoint
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Status pages are no longer part of Quick start — they're offered as a
|
|
170
|
+
// guided flow after the run completes (see the incident-ready screen).
|
|
171
|
+
|
|
172
|
+
// 5. Test alert — the "see an alert" payoff, and the thing that actually
|
|
173
|
+
// pages you. When we have an escalation policy, trigger the alert against it
|
|
174
|
+
// (urgency high) so it escalates to the on-call person and rings their phone.
|
|
175
|
+
const groupIds = teamId ? [teamId] : [];
|
|
176
|
+
const alertSummary = 'Rootly setup test alert';
|
|
177
|
+
|
|
178
|
+
let highUrgencyId = null;
|
|
179
|
+
try {
|
|
180
|
+
const urgencies = await api.listAlertUrgencies();
|
|
181
|
+
const list = urgencies?.data || [];
|
|
182
|
+
highUrgencyId = (list.find((u) => /high/i.test(u?.attributes?.name || '')) || list[0])?.id || null;
|
|
183
|
+
} catch {
|
|
184
|
+
// best-effort; urgency is optional.
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const policyId = data.escalationPolicy?.id || null;
|
|
188
|
+
const alert = await run('test-alert', () =>
|
|
189
|
+
createTestAlertAction({
|
|
190
|
+
summary: alertSummary,
|
|
191
|
+
description: 'Fired by the Rootly setup wizard to page on-call.',
|
|
192
|
+
groupIds,
|
|
193
|
+
...(policyId
|
|
194
|
+
? { notificationTargetType: 'EscalationPolicy', notificationTargetId: policyId, urgencyId: highUrgencyId }
|
|
195
|
+
: {})
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
if (alert) data.alert = { id: alert.data.id, summary: alertSummary, paged: Boolean(alert.data.paged) };
|
|
199
|
+
|
|
200
|
+
// 7. Test incident — the "see an incident" payoff. Attach a severity when the
|
|
201
|
+
// workspace exposes one (some workspaces require it).
|
|
202
|
+
let severityId = null;
|
|
203
|
+
try {
|
|
204
|
+
const severities = await api.listSeverities();
|
|
205
|
+
severityId = severities?.data?.[0]?.id || null;
|
|
206
|
+
} catch {
|
|
207
|
+
// best-effort; the incident action tolerates a null severity.
|
|
208
|
+
}
|
|
209
|
+
const incidentTitle = 'Rootly setup test incident';
|
|
210
|
+
const incident = await run('test-incident', () =>
|
|
211
|
+
createTestIncidentAction({
|
|
212
|
+
title: incidentTitle,
|
|
213
|
+
summary: 'Created by the Rootly setup wizard to show the incident flow.',
|
|
214
|
+
groupIds,
|
|
215
|
+
severityId
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
if (incident) {
|
|
219
|
+
data.incident = {
|
|
220
|
+
id: incident.data.id,
|
|
221
|
+
title: incidentTitle,
|
|
222
|
+
slackChannelName: incident.data.slackChannelName,
|
|
223
|
+
slackChannelUrl: incident.data.slackChannelUrl
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const ok = Boolean(data.alert || data.incident);
|
|
228
|
+
const note = blocked.length
|
|
229
|
+
? (isOAuth
|
|
230
|
+
? `This browser sign-in can't write ${blocked.join(', ')} yet. Sign in with an API token to complete those, or have Rootly grant the OAuth app workspace write scopes.`
|
|
231
|
+
: `Could not complete: ${blocked.join(', ')}.`)
|
|
232
|
+
: null;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
ok,
|
|
236
|
+
summary: ok
|
|
237
|
+
? `${data.incident ? 'Created a test incident' : 'Fired a test alert'}${
|
|
238
|
+
teamId ? ` for ${teamLabel}` : ''
|
|
239
|
+
}.`
|
|
240
|
+
: 'Could not create an alert or incident with this sign-in.',
|
|
241
|
+
data: { ...data, blocked, note }
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { loadApiClient, extractUserId } from '../runtime.js';
|
|
2
|
+
|
|
3
|
+
// Returns the signed-in user's existing phone number (if any) for display.
|
|
4
|
+
export async function getCurrentUserPhoneAction() {
|
|
5
|
+
const api = await loadApiClient();
|
|
6
|
+
const me = await api.getCurrentUser();
|
|
7
|
+
const userId = extractUserId(me);
|
|
8
|
+
if (!userId) {
|
|
9
|
+
return { ok: true, data: { hasPhone: false, phone: null } };
|
|
10
|
+
}
|
|
11
|
+
let phones = [];
|
|
12
|
+
try {
|
|
13
|
+
const payload = await api.getUserPhoneNumbers(userId);
|
|
14
|
+
phones = payload?.data || [];
|
|
15
|
+
} catch {
|
|
16
|
+
// best-effort; treat as no phone on error.
|
|
17
|
+
}
|
|
18
|
+
const primary = phones.find((p) => p.attributes?.primary) || phones[0] || null;
|
|
19
|
+
return {
|
|
20
|
+
ok: true,
|
|
21
|
+
data: { hasPhone: phones.length > 0, phone: primary?.attributes?.phone || null }
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatError(error) {
|
|
26
|
+
return error?.message?.replace(/^Rootly API request failed for [^:]+:\s*/, '') || 'unknown error';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Browser (OAuth) sign-ins can't manage phone numbers yet (403/404). Surface a
|
|
30
|
+
// clear next step instead of the raw API error.
|
|
31
|
+
function isPermissionFailure(error) {
|
|
32
|
+
const message = error?.message || '';
|
|
33
|
+
return /\b40[134]\b/.test(message) || /Not found or unauthorized/i.test(message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PHONE_BLOCKED_MESSAGE =
|
|
37
|
+
'This sign-in can’t add phone numbers. Sign in with an API token, or add your number in the Rootly app.';
|
|
38
|
+
|
|
39
|
+
// Add a phone number to the signed-in user and trigger a verification SMS.
|
|
40
|
+
// Returns the new phone number id so the caller can submit the code next.
|
|
41
|
+
export async function startPhoneVerificationAction({ phone } = {}) {
|
|
42
|
+
// Strip spaces, parens, dashes, dots; keep digits and a leading "+". A US
|
|
43
|
+
// number works without a country code (Rootly normalizes with a US default).
|
|
44
|
+
const clean = String(phone || '').replace(/[()\s.\-]/g, '').trim();
|
|
45
|
+
if (!clean) {
|
|
46
|
+
return { ok: false, summary: 'A phone number is required.' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const api = await loadApiClient();
|
|
50
|
+
const currentUser = await api.getCurrentUser();
|
|
51
|
+
const userId = extractUserId(currentUser);
|
|
52
|
+
if (!userId) {
|
|
53
|
+
return { ok: false, summary: 'Could not resolve the current user.' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let phoneNumberId = null;
|
|
57
|
+
try {
|
|
58
|
+
const created = await api.createUserPhoneNumber(userId, clean);
|
|
59
|
+
phoneNumberId = created?.data?.id || null;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (isPermissionFailure(error)) {
|
|
62
|
+
return { ok: false, summary: PHONE_BLOCKED_MESSAGE };
|
|
63
|
+
}
|
|
64
|
+
return { ok: false, summary: `Could not add the phone number: ${formatError(error)}` };
|
|
65
|
+
}
|
|
66
|
+
if (!phoneNumberId) {
|
|
67
|
+
return { ok: false, summary: 'The phone number could not be created.' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await api.sendPhoneVerification(phoneNumberId);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Remove the unverified number so a retry starts clean.
|
|
74
|
+
try {
|
|
75
|
+
await api.deleteUserPhoneNumber(phoneNumberId);
|
|
76
|
+
} catch {
|
|
77
|
+
// best-effort cleanup
|
|
78
|
+
}
|
|
79
|
+
if (isPermissionFailure(error)) {
|
|
80
|
+
return { ok: false, summary: PHONE_BLOCKED_MESSAGE };
|
|
81
|
+
}
|
|
82
|
+
return { ok: false, summary: `Could not send a verification code: ${formatError(error)}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
summary: `Sent a verification code to ${clean}.`,
|
|
88
|
+
data: { phoneNumberId, phone: clean }
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function confirmPhoneVerificationAction({ phoneNumberId, code } = {}) {
|
|
93
|
+
const cleanCode = String(code || '').trim();
|
|
94
|
+
if (!phoneNumberId) {
|
|
95
|
+
return { ok: false, summary: 'Missing the phone number to verify.' };
|
|
96
|
+
}
|
|
97
|
+
if (!cleanCode) {
|
|
98
|
+
return { ok: false, summary: 'A verification code is required.' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const api = await loadApiClient();
|
|
102
|
+
try {
|
|
103
|
+
await api.submitPhoneVerificationCode(phoneNumberId, cleanCode);
|
|
104
|
+
return { ok: true, summary: 'Phone number verified.', data: { phoneNumberId } };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return { ok: false, summary: `Could not verify the code: ${formatError(error)}` };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function resendPhoneVerificationAction({ phoneNumberId } = {}) {
|
|
111
|
+
if (!phoneNumberId) {
|
|
112
|
+
return { ok: false, summary: 'Missing the phone number.' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const api = await loadApiClient();
|
|
116
|
+
try {
|
|
117
|
+
await api.resendPhoneVerification(phoneNumberId);
|
|
118
|
+
return { ok: true, summary: 'Sent a new verification code.', data: { phoneNumberId } };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return { ok: false, summary: `Could not resend the code: ${formatError(error)}` };
|
|
121
|
+
}
|
|
122
|
+
}
|