ConnectAuthorizePage.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /**
  2. * "Sign in with Bambuddy" for connected apps.
  3. *
  4. * An app sends the browser here with its client_id, callback URL, PKCE
  5. * challenge and state. The page runs with the user's normal session: it asks
  6. * the backend whether the request is valid, shows a one-time consent screen,
  7. * and sends the browser back to the app with a single-use code.
  8. *
  9. * It never redirects anywhere until the backend has confirmed that the
  10. * callback URL is the one registered for that app, so it can't be used as an
  11. * open redirect. Every error before that point is shown here instead.
  12. */
  13. import { useEffect, useRef, useState } from 'react';
  14. import { useTranslation } from 'react-i18next';
  15. import { useSearchParams } from 'react-router-dom';
  16. import { AlertTriangle, Link2, Loader2 } from 'lucide-react';
  17. import { api, ApiError, type ConnectAuthorizeInfo } from '../api/client';
  18. import { useAuth } from '../contexts/AuthContext';
  19. type Phase =
  20. | { kind: 'loading' }
  21. | { kind: 'consent'; info: ConnectAuthorizeInfo }
  22. | { kind: 'redirecting' }
  23. | { kind: 'error'; message: string };
  24. function withParams(base: string, params: Record<string, string>): string {
  25. const url = new URL(base);
  26. for (const [key, value] of Object.entries(params)) {
  27. if (value) url.searchParams.set(key, value);
  28. }
  29. return url.toString();
  30. }
  31. export function ConnectAuthorizePage() {
  32. const { t } = useTranslation();
  33. const { authEnabled, loading } = useAuth();
  34. const [searchParams] = useSearchParams();
  35. const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
  36. const started = useRef(false);
  37. const clientId = searchParams.get('client_id') ?? '';
  38. const redirectUri = searchParams.get('redirect_uri') ?? '';
  39. const state = searchParams.get('state') ?? '';
  40. const codeChallenge = searchParams.get('code_challenge') ?? '';
  41. const codeChallengeMethod = searchParams.get('code_challenge_method') ?? '';
  42. const approve = async () => {
  43. setPhase({ kind: 'redirecting' });
  44. try {
  45. const result = await api.connectAuthorize({
  46. client_id: clientId,
  47. redirect_uri: redirectUri,
  48. code_challenge: codeChallenge,
  49. code_challenge_method: 'S256',
  50. });
  51. // Use the callback the backend returned, not the query parameter.
  52. window.location.replace(withParams(result.redirect_uri, { code: result.code, state }));
  53. } catch (err) {
  54. setPhase({
  55. kind: 'error',
  56. message: err instanceof Error ? err.message : t('connectedApps.authorize.failed'),
  57. });
  58. }
  59. };
  60. const deny = () => {
  61. // Safe: we only reach consent after the backend matched redirectUri
  62. // against the app's registration.
  63. window.location.replace(withParams(redirectUri, { error: 'access_denied', state }));
  64. };
  65. useEffect(() => {
  66. if (loading || started.current) return;
  67. started.current = true;
  68. if (!authEnabled) {
  69. setPhase({ kind: 'error', message: t('connectedApps.authorize.authDisabled') });
  70. return;
  71. }
  72. if (!clientId || !redirectUri || !codeChallenge || codeChallengeMethod !== 'S256') {
  73. setPhase({ kind: 'error', message: t('connectedApps.authorize.invalidRequest') });
  74. return;
  75. }
  76. api
  77. .getConnectAuthorizeInfo(clientId, redirectUri)
  78. .then((info) => {
  79. if (info.already_granted) {
  80. void approve();
  81. } else {
  82. setPhase({ kind: 'consent', info });
  83. }
  84. })
  85. .catch((err) => {
  86. const disabled = err instanceof ApiError && err.status === 409;
  87. setPhase({
  88. kind: 'error',
  89. message: disabled ? t('connectedApps.authorize.authDisabled') : t('connectedApps.authorize.invalidRequest'),
  90. });
  91. });
  92. // approve() reads the same query parameters; running this once is the point.
  93. // eslint-disable-next-line react-hooks/exhaustive-deps
  94. }, [loading, authEnabled]);
  95. return (
  96. <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
  97. <div className="max-w-md w-full space-y-6 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
  98. {(phase.kind === 'loading' || phase.kind === 'redirecting') && (
  99. <div className="flex flex-col items-center gap-3 text-bambu-gray" role="status">
  100. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  101. <p>
  102. {phase.kind === 'loading'
  103. ? t('connectedApps.authorize.checking')
  104. : t('connectedApps.authorize.redirecting')}
  105. </p>
  106. </div>
  107. )}
  108. {phase.kind === 'error' && (
  109. <div className="text-center space-y-3">
  110. <AlertTriangle className="w-10 h-10 mx-auto text-yellow-600 dark:text-yellow-400" />
  111. <h1 className="text-xl font-semibold text-white">{t('connectedApps.authorize.errorTitle')}</h1>
  112. <p className="text-sm text-bambu-gray">{phase.message}</p>
  113. </div>
  114. )}
  115. {phase.kind === 'consent' && (
  116. <>
  117. <div className="text-center space-y-3">
  118. <div className="w-14 h-14 mx-auto rounded-full bg-bambu-green/20 flex items-center justify-center">
  119. <Link2 className="w-7 h-7 text-bambu-green" />
  120. </div>
  121. <h1 className="text-xl font-semibold text-white text-balance">
  122. {t('connectedApps.authorize.title', { app: phase.info.app_name })}
  123. </h1>
  124. <p className="text-sm text-bambu-gray">
  125. {t('connectedApps.authorize.signedInAs', { username: phase.info.username })}
  126. </p>
  127. </div>
  128. <p className="text-sm text-bambu-gray">
  129. {t('connectedApps.authorize.sharedData', { app: phase.info.app_name })}
  130. </p>
  131. <div className="flex gap-3">
  132. <button
  133. type="button"
  134. onClick={deny}
  135. className="flex-1 px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-bambu-green"
  136. >
  137. {t('connectedApps.authorize.deny')}
  138. </button>
  139. <button
  140. type="button"
  141. onClick={() => void approve()}
  142. className="flex-1 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
  143. >
  144. {t('connectedApps.authorize.allow')}
  145. </button>
  146. </div>
  147. </>
  148. )}
  149. </div>
  150. </div>
  151. );
  152. }