@marsaude/devtools-shell 0.1.0 → 0.1.2

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.
@@ -1138,6 +1138,11 @@ class DevtoolsApiService {
1138
1138
  this.config = inject(DEVTOOLS_CONFIG);
1139
1139
  this.utils = inject(DevtoolsUtils);
1140
1140
  this.typeform = inject(DevtoolsTypeform);
1141
+ // ---- reCAPTCHA (v2 Invisible) -----------------------------------------
1142
+ /** Cached invisible-widget id: rendered once, re-executed per request. */
1143
+ this.recaptchaWidgetId = null;
1144
+ /** Resolver for the in-flight execute() — the widget callback fires this. */
1145
+ this.recaptchaResolver = null;
1141
1146
  }
1142
1147
  get api() {
1143
1148
  return this.config.apiBaseUrl;
@@ -1296,14 +1301,43 @@ class DevtoolsApiService {
1296
1301
  return of([]);
1297
1302
  return forkJoin(valid.map((id) => this.deleteAuthStatement(id)));
1298
1303
  }
1299
- // ---- reCAPTCHA --------------------------------------------------------
1300
1304
  recaptcha() {
1301
1305
  const key = this.config.recaptchaSiteKey;
1302
- const grecaptcha = window.grecaptcha;
1303
- if (!key || !grecaptcha?.execute)
1306
+ if (!key) {
1307
+ console.warn('[devtools] recaptchaSiteKey not configured — request will be sent without a reCAPTCHA token');
1304
1308
  return of('');
1305
- return from(new Promise((resolve) => {
1306
- grecaptcha.ready(() => grecaptcha.execute(key, { action: 'login' }).then(resolve).catch(() => resolve('')));
1309
+ }
1310
+ return from(ensureRecaptchaScript()
1311
+ .then((grecaptcha) => new Promise((resolve) => {
1312
+ // v2 Invisible delivers the token via callback, not a promise.
1313
+ // Point the shared resolver at THIS request before executing.
1314
+ this.recaptchaResolver = (token) => {
1315
+ this.recaptchaResolver = null;
1316
+ try {
1317
+ grecaptcha.reset(this.recaptchaWidgetId);
1318
+ }
1319
+ catch {
1320
+ /* ignore */
1321
+ }
1322
+ resolve(token || '');
1323
+ };
1324
+ if (this.recaptchaWidgetId == null) {
1325
+ const host = document.createElement('div');
1326
+ host.style.display = 'none';
1327
+ document.body.appendChild(host);
1328
+ this.recaptchaWidgetId = grecaptcha.render(host, {
1329
+ sitekey: key,
1330
+ size: 'invisible',
1331
+ callback: (token) => this.recaptchaResolver?.(token),
1332
+ 'error-callback': () => this.recaptchaResolver?.(''),
1333
+ 'expired-callback': () => this.recaptchaResolver?.(''),
1334
+ });
1335
+ }
1336
+ grecaptcha.execute(this.recaptchaWidgetId);
1337
+ }))
1338
+ .catch((e) => {
1339
+ console.error('[devtools] failed to load reCAPTCHA', e);
1340
+ return '';
1307
1341
  }));
1308
1342
  }
1309
1343
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: DevtoolsApiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
@@ -1312,6 +1346,42 @@ class DevtoolsApiService {
1312
1346
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: DevtoolsApiService, decorators: [{
1313
1347
  type: Injectable
1314
1348
  }] });
1349
+ const RECAPTCHA_SCRIPT_ID = 'dc-recaptcha-v2-invisible';
1350
+ /**
1351
+ * Lazy-load the reCAPTCHA v2 (explicit render) script (once) and resolve with
1352
+ * the `grecaptcha` API. The host only passes `recaptchaSiteKey`; the package
1353
+ * injects the script itself so nothing depends on the host having it loaded.
1354
+ */
1355
+ function ensureRecaptchaScript() {
1356
+ const w = window;
1357
+ if (w.grecaptcha?.render)
1358
+ return Promise.resolve(w.grecaptcha);
1359
+ return new Promise((resolve, reject) => {
1360
+ const onReady = () => {
1361
+ if (w.grecaptcha?.render)
1362
+ resolve(w.grecaptcha);
1363
+ else
1364
+ reject(new Error('grecaptcha unavailable after script load'));
1365
+ };
1366
+ const existing = document.getElementById(RECAPTCHA_SCRIPT_ID);
1367
+ if (existing) {
1368
+ if (w.grecaptcha?.render)
1369
+ return resolve(w.grecaptcha);
1370
+ existing.addEventListener('load', onReady, { once: true });
1371
+ existing.addEventListener('error', () => reject(new Error('reCAPTCHA script failed to load')), { once: true });
1372
+ return;
1373
+ }
1374
+ const script = document.createElement('script');
1375
+ script.id = RECAPTCHA_SCRIPT_ID;
1376
+ // `render=explicit` so we control the (invisible) widget creation ourselves.
1377
+ script.src = 'https://www.google.com/recaptcha/api.js?render=explicit';
1378
+ script.async = true;
1379
+ script.defer = true;
1380
+ script.onload = onReady;
1381
+ script.onerror = () => reject(new Error('reCAPTCHA script failed to load'));
1382
+ document.head.appendChild(script);
1383
+ });
1384
+ }
1315
1385
  function onlyDigits(v) {
1316
1386
  return (v ?? '').replace(/\D/g, '');
1317
1387
  }
@@ -1348,11 +1418,15 @@ class DevtoolsAuthService {
1348
1418
  /** Begin Google OAuth for the admin (full-page redirect). */
1349
1419
  loginGoogleRedirect() {
1350
1420
  const clientId = this.config.googleClientId;
1351
- const redirect = this.config.googleRedirectUri || window.location.origin;
1421
+ const redirect = this.config.googleRedirectUri;
1352
1422
  if (!clientId) {
1353
1423
  console.error('[devtools] googleClientId not configured');
1354
1424
  return;
1355
1425
  }
1426
+ if (!redirect) {
1427
+ console.error('[devtools] googleRedirectUri not configured — set it to a URI whitelisted in the Google OAuth client');
1428
+ return;
1429
+ }
1356
1430
  try {
1357
1431
  sessionStorage.setItem(OAUTH_FLAG, '1');
1358
1432
  }
@@ -1377,11 +1451,17 @@ class DevtoolsAuthService {
1377
1451
  catch {
1378
1452
  /* ignore */
1379
1453
  }
1380
- if (!pending || !window.location.hash)
1454
+ if (!pending)
1381
1455
  return;
1382
- const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
1456
+ // Implicit flow returns everything in the URL fragment. We read the query
1457
+ // too so a misconfigured response_type still surfaces an error instead of
1458
+ // failing silently.
1459
+ const params = new URLSearchParams(window.location.hash.replace(/^#/, '') || window.location.search.replace(/^\?/, ''));
1383
1460
  const googleToken = params.get('access_token');
1384
- if (!googleToken)
1461
+ const error = params.get('error');
1462
+ // Neither token nor error here => this isn't our OAuth landing yet; keep the
1463
+ // flag so the real return (the whitelisted redirect_uri) can consume it.
1464
+ if (!googleToken && !error)
1385
1465
  return;
1386
1466
  try {
1387
1467
  sessionStorage.removeItem(OAUTH_FLAG);
@@ -1389,18 +1469,25 @@ class DevtoolsAuthService {
1389
1469
  catch {
1390
1470
  /* ignore */
1391
1471
  }
1472
+ if (error) {
1473
+ console.error('[devtools] google oauth returned an error:', error, params.get('error_description') ?? '');
1474
+ this.cleanOAuthUrl();
1475
+ return;
1476
+ }
1392
1477
  this.exchangeGoogle(googleToken).subscribe({
1393
- next: () => {
1394
- try {
1395
- history.replaceState(null, '', window.location.pathname + window.location.search);
1396
- }
1397
- catch {
1398
- /* ignore */
1399
- }
1400
- },
1478
+ next: () => this.cleanOAuthUrl(),
1401
1479
  error: (e) => console.error('[devtools] admin google login failed', e),
1402
1480
  });
1403
1481
  }
1482
+ /** Strip the OAuth fragment/query so a reload doesn't re-trigger the return. */
1483
+ cleanOAuthUrl() {
1484
+ try {
1485
+ history.replaceState(null, '', window.location.pathname + window.location.search);
1486
+ }
1487
+ catch {
1488
+ /* ignore */
1489
+ }
1490
+ }
1404
1491
  exchangeGoogle(googleToken) {
1405
1492
  return this.api.exchangeGoogle(googleToken).pipe(tap((tokens) => this.setTokens(tokens)), tap(() => this.api.self(this._token() ?? undefined).subscribe((p) => this.storage.set(ADMIN_USER, p))));
1406
1493
  }