diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1a6bfaf2..a59357f0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -14,3 +14,4 @@ export * from './useDataTableState'; export * from './useIdpImportGate'; export * from './useSsoManaged'; export * from './useSsoRequired'; +export * from './useSsoStatus'; diff --git a/src/hooks/useSsoManaged.ts b/src/hooks/useSsoManaged.ts index 8eb167c1..b723aa3d 100644 --- a/src/hooks/useSsoManaged.ts +++ b/src/hooks/useSsoManaged.ts @@ -1,29 +1,11 @@ -import { getSsoStatus } from '@/services/sso'; -import { localStorageGet } from '@/utils'; -import { useEffect, useState } from 'react'; +import { useSsoStatus } from './useSsoStatus'; export function useSsoManaged(): boolean { - const [managed, setManaged] = useState(false); + const status = useSsoStatus(); - useEffect(() => { - if (typeof window !== 'undefined' && localStorage.getItem('sso_managed_override') === 'true') { - setManaged(true); - return; - } + if (typeof window !== 'undefined' && localStorage.getItem('sso_managed_override') === 'true') { + return true; + } - const apiUrl = localStorageGet('api_url'); - if (!apiUrl) return; - - let cancelled = false; - - getSsoStatus(apiUrl).then((status) => { - if (!cancelled) setManaged(status?.enabled === true && status.provider !== null); - }); - - return () => { - cancelled = true; - }; - }, []); - - return managed; + return status?.enabled === true && status.provider !== null; } diff --git a/src/hooks/useSsoRequired.ts b/src/hooks/useSsoRequired.ts index add2a4c9..c6630772 100644 --- a/src/hooks/useSsoRequired.ts +++ b/src/hooks/useSsoRequired.ts @@ -1,43 +1,13 @@ -import { getSsoStatus } from '@/services/sso'; -import { localStorageGet } from '@/utils'; -import { useEffect, useState } from 'react'; +import { useSsoStatus } from './useSsoStatus'; /** * Whether the instance mandates SSO. `undefined` while the status is still * being resolved, so callers can gate rendering instead of failing open. */ export function useSsoRequired(): boolean | undefined { - const [required, setRequired] = useState(undefined); + const status = useSsoStatus(); - useEffect(() => { - let cancelled = false; - let timer: ReturnType; + if (status === undefined) return undefined; - // api_url is written asynchronously by useInstanceGuard (and, in - // multi-instance mode, only once the user enters their code), so poll for - // it before querying rather than settling on a permissive default. - const check = async () => { - if (cancelled) return; - - const apiUrl = localStorageGet('api_url'); - if (!apiUrl) { - timer = setTimeout(check, 200); - return; - } - - const status = await getSsoStatus(apiUrl); - if (!cancelled) { - setRequired(status?.enabled === true && status.provider !== null && status.required === true); - } - }; - - check(); - - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, []); - - return required; + return status?.enabled === true && status.provider !== null && status.required === true; } diff --git a/src/hooks/useSsoStatus.ts b/src/hooks/useSsoStatus.ts new file mode 100644 index 00000000..9e5b361b --- /dev/null +++ b/src/hooks/useSsoStatus.ts @@ -0,0 +1,42 @@ +import { getSsoStatus, SsoStatus } from '@/services/sso'; +import { localStorageGet } from '@/utils'; +import { useEffect, useState } from 'react'; + +/** + * The instance's SSO status, fetched once. + * + * `undefined` while unresolved (callers gate rendering instead of failing + * open); `null` when the backend could not answer. api_url is written + * asynchronously by useInstanceGuard — and only once the user enters their + * code in multi-instance mode — so poll for it before querying. + */ +export function useSsoStatus(): SsoStatus | null | undefined { + const [status, setStatus] = useState(undefined); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType; + + const check = async () => { + if (cancelled) return; + + const apiUrl = localStorageGet('api_url'); + if (!apiUrl) { + timer = setTimeout(check, 200); + return; + } + + const result = await getSsoStatus(apiUrl); + if (!cancelled) setStatus(result); + }; + + check(); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, []); + + return status; +} diff --git a/src/locale/de.json b/src/locale/de.json index 53b6c97a..ca8626f0 100644 --- a/src/locale/de.json +++ b/src/locale/de.json @@ -107,6 +107,7 @@ "a11y": { "navigation": "Navigationsmenü", "skipToContent": "Zum Hauptinhalt springen", + "loading": "Wird geladen", "breadcrumb": { "label": "Breadcrumb-Navigation", "description": "Navigieren von {{var}} zu vorherigen Seiten", @@ -1201,4 +1202,4 @@ "continue": "Trotzdem fortfahren" } } -} \ No newline at end of file +} diff --git a/src/locale/en.json b/src/locale/en.json index a6a564ee..5a54871a 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -107,6 +107,7 @@ "a11y": { "navigation": "Navigation menu", "skipToContent": "Skip to main content", + "loading": "Loading", "breadcrumb": { "label": "Breadcrumb navigation", "description": "Navigate from {{var}} to previous pages", diff --git a/src/v2/views/public/Login/LoginView.tsx b/src/v2/views/public/Login/LoginView.tsx index fedd37a6..938fcc1f 100644 --- a/src/v2/views/public/Login/LoginView.tsx +++ b/src/v2/views/public/Login/LoginView.tsx @@ -1,8 +1,11 @@ +import Eduplaces from '@/components/Buttons/Eduplaces/Eduplaces'; +import { MIN_SSO_SAFARI_VERSION } from '@/utils'; import Button from '@/v2/components/button/Button'; import TextInput from '@/v2/components/input/TextInput'; -import Link from '@/v2/components/navigation/Link'; import InstanceCodeField from '@/v2/components/input/InstanceCodeField'; import { useInstanceCode } from '@/v2/components/input/InstanceCodeField/useInstanceCode'; +import Link from '@/v2/components/navigation/Link'; +import Collapse from '@/v2/components/ui/Collapse'; import { yupResolver } from '@hookform/resolvers/yup'; import React, { useMemo } from 'react'; import { useForm } from 'react-hook-form'; @@ -15,7 +18,23 @@ const MAX_PASSWORD_LENGTH = 64; const LoginView: React.FC = () => { const { t } = useTranslation(); - const { onSubmit, isLoading, isSsoLoading, config, handleSsoLogin } = useLoginSubmit(); + const { + onSubmit, + isLoading, + isSsoLoading, + handleSsoLogin, + loginError, + setError, + linkBanner, + setLinkBanner, + ssoAvailable, + showPasswordLogin, + ssoStatusPending, + ssoBrowserSupported, + ssoLinkToken, + claimable, + declineClaim, + } = useLoginSubmit(); const { instanceCode, setInstanceCode, @@ -58,63 +77,121 @@ const LoginView: React.FC = () => {

{t('v2.page.login.title', { var: 'Aula' })}

-
- {showField && ( - { - validateCode(); - }} - disabled={isLoading || codeLoading} - /> - )} - - - - - {t('v2.page.recovery.link')} - -
- - {config?.IS_SSO_ENABLED && ( - <> -
-
- {t('ui.common.or')} -
-
-
- {' '} + ✕ +
+ {claimable && ssoLinkToken && ( + + )} +
+ + + +
+ {loginError} + +
+
+ + {ssoStatusPending ? ( +
+ ) : ( + <> + {(showField || showPasswordLogin) && ( +
+ {showField && ( + { + validateCode(); + }} + disabled={isLoading || codeLoading} + /> + )} + {showPasswordLogin && ( + <> + + + + + {t('v2.page.recovery.link')} + + + )} +
+ )} + + {ssoAvailable && ssoLinkToken === null && ( + <> + {showPasswordLogin && ( +
+
+ {t('ui.common.or')} +
+
+ )} +
+ {!ssoBrowserSupported && ( +
+ {t('auth.sso.unsupportedBrowser', { version: MIN_SSO_SAFARI_VERSION })} +
+ )} + handleSsoLogin()} + disabled={isLoading || isSsoLoading} + /> +

{t('auth.sso.hint')}

+
+ + )} )} diff --git a/src/v2/views/public/Login/useLoginSubmit.ts b/src/v2/views/public/Login/useLoginSubmit.ts index a3df9db0..f5d52355 100644 --- a/src/v2/views/public/Login/useLoginSubmit.ts +++ b/src/v2/views/public/Login/useLoginSubmit.ts @@ -1,10 +1,15 @@ import { getRuntimeConfig, loadRuntimeConfig, RuntimeConfig } from '@/config'; +import { useSsoStatus } from '@/hooks'; +import { handleOAuthLogin } from '@/services/auth'; +import { declineAccountClaim } from '@/services/idpMigration'; import { loginUser } from '@/services/login'; import { completeSsoLink, initiateSso } from '@/services/sso'; import { useAppStore } from '@/store'; -import { localStorageGet, localStorageSet, parseJwt } from '@/utils'; +import { isSsoBrowserSupported, localStorageGet, localStorageSet, MIN_SSO_SAFARI_VERSION, parseJwt } from '@/utils'; import { useToast } from '@/v2/hooks'; -import { useEffect, useState } from 'react'; +import { Browser } from '@capacitor/browser'; +import { Capacitor } from '@capacitor/core'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -24,16 +29,32 @@ export const useLoginSubmit = () => { const [loginError, setError] = useState(''); const [linkBanner, setLinkBanner] = useState(''); const [ssoLinkToken, setSsoLinkToken] = useState(null); + /** True when nobody knows yet whether this person has an aula account. */ + const [claimable, setClaimable] = useState(false); const [config, setConfig] = useState(null); + const ssoStatus = useSsoStatus(); + const ssoBrowserSupported = useMemo(() => isSsoBrowserSupported(), []); + + const ssoAvailable = !!config?.IS_SSO_ENABLED && ssoStatus?.enabled === true; + const ssoEnforced = ssoAvailable && ssoStatus?.required === true; + const showPasswordLogin = !ssoEnforced || ssoLinkToken !== null; + const ssoStatusPending = + ssoLinkToken === null && (config === null || (!!config.IS_SSO_ENABLED && ssoStatus === undefined)); + useEffect(() => { const ssoError = searchParams.get('sso_error'); const ssoLink = searchParams.get('sso_link'); if (ssoError === 'account_link_required' && ssoLink) { setSsoLinkToken(ssoLink); + // A claimable link comes from a school mid-migration: nobody knows yet + // whether this person already has an aula account, so they have to be + // able to say they do not. + const isClaimable = searchParams.get('claimable') === '1'; + setClaimable(isClaimable); setLinkBanner( - t('errors.sso.account_link_required', { + t(isClaimable ? 'idp.claim.banner' : 'errors.sso.account_link_required', { defaultValue: 'We found an existing account for the email returned by your SSO provider. Log in once with your aula password to link the accounts; future SSO logins will go through directly.', }) @@ -133,7 +154,7 @@ export const useLoginSubmit = () => { } }; - const handleSsoLogin = async () => { + const handleSsoLogin = async (options: { loginHint?: string } = {}) => { const instanceApiUrl = localStorageGet('api_url'); if (!instanceApiUrl) { toast.error(t('errors.noServer')); @@ -141,12 +162,65 @@ export const useLoginSubmit = () => { } try { setSsoLoading(true); - window.location.href = await initiateSso(instanceApiUrl); + const url = await initiateSso(instanceApiUrl, options); + + if (Capacitor.isNativePlatform()) { + // A Custom Tab keeps the login layered over the app; assigning + // window.location here would send the WebView off-origin and Capacitor + // would hand it to the system browser, stranding the user outside. + await Browser.open({ url }); + return; + } + + window.location.href = url; } catch { setSsoLoading(false); toast.error(t('errors.default')); } }; - return { onSubmit, isLoading, isSsoLoading, loginError, setError, linkBanner, setLinkBanner, config, handleSsoLogin }; + // IdP-initiated entry (e.g. Eduplaces marketplace launch) lands here with + // ?via=eduplaces. Trigger SSO automatically, preserving the upstream + // login_hint so the user is not asked to identify themselves again. + useEffect(() => { + if (searchParams.get('via') !== 'eduplaces') return; + if (ssoStatus === undefined) return; + if (!ssoAvailable) return; + if (!ssoBrowserSupported) return; + const loginHint = searchParams.get('login_hint') ?? undefined; + handleSsoLogin({ loginHint }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, ssoAvailable, ssoStatus]); + + const declineClaim = async () => { + if (!ssoLinkToken) return; + const jwt = await declineAccountClaim(ssoLinkToken); + if (!jwt) { + setError(t('idp.claim.declineFailed')); + return; + } + handleOAuthLogin(jwt); + dispatch({ type: 'LOG_IN' }); + navigate('/', { replace: true }); + }; + + return { + onSubmit, + isLoading, + isSsoLoading, + loginError, + setError, + linkBanner, + setLinkBanner, + config, + handleSsoLogin, + ssoAvailable, + ssoEnforced, + showPasswordLogin, + ssoStatusPending, + ssoBrowserSupported, + ssoLinkToken, + claimable, + declineClaim, + }; };