Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './useDataTableState';
export * from './useIdpImportGate';
export * from './useSsoManaged';
export * from './useSsoRequired';
export * from './useSsoStatus';
30 changes: 6 additions & 24 deletions src/hooks/useSsoManaged.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 4 additions & 34 deletions src/hooks/useSsoRequired.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined>(undefined);
const status = useSsoStatus();

useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;
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;
}
42 changes: 42 additions & 0 deletions src/hooks/useSsoStatus.ts
Original file line number Diff line number Diff line change
@@ -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<SsoStatus | null | undefined>(undefined);

useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;

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;
}
3 changes: 2 additions & 1 deletion src/locale/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1201,4 +1202,4 @@
"continue": "Trotzdem fortfahren"
}
}
}
}
1 change: 1 addition & 0 deletions src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
191 changes: 134 additions & 57 deletions src/v2/views/public/Login/LoginView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -58,63 +77,121 @@ const LoginView: React.FC = () => {
<form onSubmit={handleSubmit(wrappedSubmit)} noValidate className="flex-1 flex flex-col gap-4">
<h1>{t('v2.page.login.title', { var: 'Aula' })}</h1>

<div className="rounded-box flex flex-col gap-2">
{showField && (
<InstanceCodeField
value={instanceCode}
onChange={setInstanceCode}
error={codeError}
isEditing={isEditing}
onEditClick={startEditing}
onConfirmClick={() => {
validateCode();
}}
disabled={isLoading || codeLoading}
/>
)}
<TextInput
label={t('v2.form.login.label')}
required
autoComplete="username"
autoCapitalize="none"
error={errors.username?.message}
{...register('username')}
/>
<TextInput
label={t('v2.form.password.label')}
type="password"
required
autoComplete="current-password"
autoCapitalize="none"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" disabled={isLoading || codeLoading} data-testid="submit-login">
{t('v2.page.login.button')}
</Button>
<Link to="/recovery" className="ml-auto px-2 text-sm text-text-secondary mt-4">
{t('v2.page.recovery.link')}
</Link>
</div>

{config?.IS_SSO_ENABLED && (
<>
<div className="flex items-center gap-2 text-muted">
<div className="flex-1 border-t border-current" />
<span className="text-sm">{t('ui.common.or')}</span>
<div className="flex-1 border-t border-current" />
</div>
<div className="flex flex-col gap-2 items-center">
<Button
outlined
color="secondary"
onClick={handleSsoLogin}
disabled={isLoading || isSsoLoading}
aria-label={t('auth.sso.arialabel')}
<Collapse open={linkBanner !== ''}>
<div className="flex flex-col gap-2 rounded-box bg-info text-info-fg p-3 text-sm" role="status">
<div className="flex items-start justify-between gap-2">
<span>{linkBanner}</span>
<button
type="button"
onClick={() => setLinkBanner('')}
aria-label={t('actions.close')}
className="shrink-0 opacity-70 hover:opacity-100"
>
{t('auth.sso.button')}
</Button>{' '}
βœ•
</button>
</div>
{claimable && ssoLinkToken && (
<Button color="secondary" onClick={declineClaim} data-testid="idp-claim-decline" className="self-start">
{t('idp.claim.noAccount')}
</Button>
)}
</div>
</Collapse>

<Collapse open={loginError !== ''}>
<div
className="flex items-start justify-between gap-2 rounded-box bg-error text-error-fg p-3 text-sm"
role="alert"
>
<span>{loginError}</span>
<button
type="button"
onClick={() => setError('')}
aria-label={t('actions.close')}
className="shrink-0 opacity-70 hover:opacity-100"
>
βœ•
</button>
</div>
</Collapse>

{ssoStatusPending ? (
<div
role="status"
aria-label={t('v2.ui.a11y.loading')}
className="mx-auto my-4 h-6 w-6 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
) : (
<>
{(showField || showPasswordLogin) && (
<div className="rounded-box flex flex-col gap-2">
{showField && (
<InstanceCodeField
value={instanceCode}
onChange={setInstanceCode}
error={codeError}
isEditing={isEditing}
onEditClick={startEditing}
onConfirmClick={() => {
validateCode();
}}
disabled={isLoading || codeLoading}
/>
)}
{showPasswordLogin && (
<>
<TextInput
label={t('v2.form.login.label')}
required
autoComplete="username"
autoCapitalize="none"
error={errors.username?.message}
{...register('username')}
/>
<TextInput
label={t('v2.form.password.label')}
type="password"
required
autoComplete="current-password"
autoCapitalize="none"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" disabled={isLoading || codeLoading} data-testid="submit-login">
{t('v2.page.login.button')}
</Button>
<Link to="/recovery" className="ml-auto px-2 text-sm text-text-secondary mt-4">
{t('v2.page.recovery.link')}
</Link>
</>
)}
</div>
)}

{ssoAvailable && ssoLinkToken === null && (
<>
{showPasswordLogin && (
<div className="flex items-center gap-2 text-muted">
<div className="flex-1 border-t border-current" />
<span className="text-sm">{t('ui.common.or')}</span>
<div className="flex-1 border-t border-current" />
</div>
)}
<div className="flex flex-col gap-2 items-center">
{!ssoBrowserSupported && (
<div className="rounded-box bg-error text-error-fg p-3 text-sm" role="alert">
{t('auth.sso.unsupportedBrowser', { version: MIN_SSO_SAFARI_VERSION })}
</div>
)}
<Eduplaces
label={t('auth.sso.button')}
onClick={() => handleSsoLogin()}
disabled={isLoading || isSsoLoading}
/>
<p className="text-sm text-text-secondary text-center">{t('auth.sso.hint')}</p>
</div>
</>
)}
</>
)}
</form>
Expand Down
Loading