Skip to content

Commit 995628b

Browse files
feat(client): support login & account linking with OpenID Connect
1 parent e7f50a4 commit 995628b

File tree

10 files changed

+440
-77
lines changed

10 files changed

+440
-77
lines changed

public/images/openid.svg

Lines changed: 57 additions & 0 deletions
Loading

src/components/Common/Button/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ type BaseProps<P> = {
3131
) => void;
3232
};
3333

34-
type ButtonProps<P extends React.ElementType> = {
34+
export type ButtonProps<P extends React.ElementType> = {
3535
as?: P;
3636
} & MergeElementProps<P, BaseProps<P>>;
3737

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Button, { type ButtonProps } from '@app/components/Common/Button';
2+
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
3+
import { type PropsWithChildren } from 'react';
4+
import { twMerge } from 'tailwind-merge';
5+
6+
export type LoginButtonProps = ButtonProps<'button'> &
7+
PropsWithChildren<{
8+
loading?: boolean;
9+
}>;
10+
11+
export default function LoginButton({
12+
loading,
13+
className,
14+
children,
15+
...buttonProps
16+
}: LoginButtonProps) {
17+
return (
18+
<Button
19+
className={twMerge(
20+
'relative flex-grow bg-transparent disabled:opacity-50',
21+
className
22+
)}
23+
disabled={loading}
24+
{...buttonProps}
25+
>
26+
{loading && (
27+
<div className="absolute right-0 mr-4 h-4 w-4">
28+
<SmallLoadingSpinner />
29+
</div>
30+
)}
31+
32+
{children}
33+
</Button>
34+
);
35+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
clearOidcProviderSlug,
3+
getOidcErrorMessage,
4+
getOidcProviderSlug,
5+
initiateOidcLogin,
6+
processOidcCallback,
7+
} from '@app/utils/oidc';
8+
import type { PublicOidcProvider } from '@server/lib/settings';
9+
import { useRouter, useSearchParams } from 'next/navigation';
10+
import { useCallback, useEffect, useState } from 'react';
11+
import { useIntl } from 'react-intl';
12+
import LoginButton from './LoginButton';
13+
14+
type OidcLoginButtonProps = {
15+
provider: PublicOidcProvider;
16+
onError?: (message: string) => void;
17+
};
18+
19+
export default function OidcLoginButton({
20+
provider,
21+
onError,
22+
}: OidcLoginButtonProps) {
23+
const intl = useIntl();
24+
const searchParams = useSearchParams();
25+
const router = useRouter();
26+
27+
const [loading, setLoading] = useState(false);
28+
29+
const redirectToLogin = useCallback(async () => {
30+
setLoading(true);
31+
try {
32+
await initiateOidcLogin(provider.slug, window.location.href);
33+
} catch (e) {
34+
setLoading(false);
35+
const errorCode = (e as { response?: { data?: { error?: string } } })
36+
?.response?.data?.error;
37+
onError?.(getOidcErrorMessage(errorCode, provider.name, intl));
38+
}
39+
}, [provider, intl, onError]);
40+
41+
const handleCallback = useCallback(async () => {
42+
setLoading(true);
43+
const result = await processOidcCallback(provider.slug);
44+
if (result.type === 'success') {
45+
router.push('/');
46+
} else {
47+
router.replace('/login');
48+
setLoading(false);
49+
onError?.(getOidcErrorMessage(result.errorCode, provider.name, intl));
50+
}
51+
}, [provider, intl, onError, router]);
52+
53+
useEffect(() => {
54+
if (loading) return;
55+
const code = searchParams.get('code');
56+
57+
if (code != null && getOidcProviderSlug() === provider.slug) {
58+
clearOidcProviderSlug();
59+
// OIDC provider has redirected back with an authorization code
60+
handleCallback();
61+
} else if (code == null && searchParams.get('provider') === provider.slug) {
62+
// Support direct redirect via ?provider=slug query param
63+
redirectToLogin();
64+
}
65+
// eslint-disable-next-line react-hooks/exhaustive-deps
66+
}, []);
67+
68+
return (
69+
<LoginButton loading={loading} onClick={() => redirectToLogin()}>
70+
{/* eslint-disable-next-line @next/next/no-img-element */}
71+
<img
72+
src={provider.logo || '/images/openid.svg'}
73+
alt={provider.name}
74+
className="mr-2 max-h-5 w-5"
75+
/>
76+
<span>{provider.name}</span>
77+
</LoginButton>
78+
);
79+
}

src/components/Login/PlexLoginButton.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import PlexIcon from '@app/assets/services/plex.svg';
2-
import Button from '@app/components/Common/Button';
3-
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
42
import usePlexLogin from '@app/hooks/usePlexLogin';
53
import defineMessages from '@app/utils/defineMessages';
64
import { Fragment } from 'react';
75
import { FormattedMessage } from 'react-intl';
6+
import LoginButton from './LoginButton';
87

98
const messages = defineMessages('components.Login', {
109
loginwithapp: 'Login with {appName}',
@@ -26,18 +25,12 @@ const PlexLoginButton = ({
2625
const { loading, login } = usePlexLogin({ onAuthToken, onError });
2726

2827
return (
29-
<Button
30-
className="relative flex-1 border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)] disabled:opacity-50"
28+
<LoginButton
29+
className="border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)]"
3130
onClick={login}
32-
disabled={loading || isProcessing}
31+
loading={loading || isProcessing}
3332
data-testid="plex-login-button"
3433
>
35-
{loading && (
36-
<div className="absolute right-0 mr-4 h-4 w-4">
37-
<SmallLoadingSpinner />
38-
</div>
39-
)}
40-
4134
{large ? (
4235
<FormattedMessage
4336
{...messages.loginwithapp}
@@ -60,7 +53,7 @@ const PlexLoginButton = ({
6053
) : (
6154
<PlexIcon className="w-8" />
6255
)}
63-
</Button>
56+
</LoginButton>
6457
);
6558
};
6659

0 commit comments

Comments
 (0)