Merge pull request #889 from Stremio/feat/example-apple-login

App: Implement Apple login
This commit is contained in:
Timothy Z. 2025-04-15 11:55:23 +03:00 committed by GitHub
commit a8931d94df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 214 additions and 7 deletions

18
package-lock.json generated
View file

@ -12,7 +12,7 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.49.2",
"@stremio/stremio-core-web": "0.49.3",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.60",
"a-color-picker": "1.2.1",
@ -23,6 +23,7 @@
"filter-invalid-dom-props": "3.0.1",
"hat": "^0.0.3",
"i18next": "^24.0.5",
"jwt-decode": "^4.0.0",
"langs": "github:Stremio/nodejs-langs",
"lodash.debounce": "4.0.8",
"lodash.intersection": "4.4.0",
@ -3371,9 +3372,9 @@
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
},
"node_modules/@stremio/stremio-core-web": {
"version": "0.49.2",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.2.tgz",
"integrity": "sha512-IYU+pdHkq4iEfqZ9G+DFZheIE53nY8XyhI1OJLvZp68/4ntRwssXwfj9InHK2Wau20fH+oV2KD1ZWb0CsTLqPA==",
"version": "0.49.3",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.3.tgz",
"integrity": "sha512-Ql/08LbwU99IUL6fOLy+v1Iv75boHXpunEPScKgXJALdq/OV5tZLG/IycN0O+5+50Nc/NHrI6HslnMNLTWA8JQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "7.24.1"
@ -10234,6 +10235,15 @@
"node": ">=4.0"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View file

@ -16,7 +16,7 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.49.2",
"@stremio/stremio-core-web": "0.49.3",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.60",
"a-color-picker": "1.2.1",
@ -27,6 +27,7 @@
"filter-invalid-dom-props": "3.0.1",
"hat": "^0.0.3",
"i18next": "^24.0.5",
"jwt-decode": "^4.0.0",
"langs": "github:Stremio/nodejs-langs",
"lodash.debounce": "4.0.8",
"lodash.intersection": "4.4.0",

View file

@ -15,6 +15,7 @@
<div id="app"></div>
<%= htmlWebpackPlugin.tags.bodyTags %>
<script src="//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
<script async src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
</body>
</html>

View file

@ -12,6 +12,8 @@ const { Button, Image, Checkbox } = require('stremio/components');
const CredentialsTextInput = require('./CredentialsTextInput');
const PasswordResetModal = require('./PasswordResetModal');
const useFacebookLogin = require('./useFacebookLogin');
const { default: useAppleLogin } = require('./useAppleLogin');
const styles = require('./styles');
const SIGNUP_FORM = 'signup';
@ -22,6 +24,7 @@ const Intro = ({ queryParams }) => {
const { t } = useTranslation();
const routeFocused = useRouteFocused();
const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin();
const [startAppleLogin, stopAppleLogin] = useAppleLogin();
const emailRef = React.useRef(null);
const passwordRef = React.useRef(null);
const confirmPasswordRef = React.useRef(null);
@ -106,6 +109,37 @@ const Intro = ({ queryParams }) => {
stopFacebookLogin();
closeLoaderModal();
}, []);
const loginWithApple = React.useCallback(() => {
openLoaderModal();
startAppleLogin()
.then(({ email, token, sub, name }) => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'Authenticate',
args: {
type: 'Apple',
token,
sub,
email,
name
}
}
});
})
.catch((error) => {
closeLoaderModal();
if (error.error === 'popup_closed_by_user') {
dispatch({ type: 'error', error: 'Apple login popup was closed.' });
} else {
dispatch({ type: 'error', error: error.error });
}
});
}, []);
const cancelLoginWithApple = React.useCallback(() => {
stopAppleLogin();
closeLoaderModal();
}, []);
const loginWithEmail = React.useCallback(() => {
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
dispatch({ type: 'error', error: 'Invalid email' });
@ -336,7 +370,7 @@ const Intro = ({ queryParams }) => {
</div>
}
{
state.error.length > 0 ?
state.error && state.error.length > 0 ?
<div ref={errorRef} className={styles['error-message']}>{state.error}</div>
:
null
@ -350,6 +384,10 @@ const Intro = ({ queryParams }) => {
<Icon className={styles['icon']} name={'facebook'} />
<div className={styles['label']}>Continue with Facebook</div>
</Button>
<Button className={classnames(styles['form-button'], styles['apple-button'])} onClick={loginWithApple}>
<Icon className={styles['icon']} name={'macos'} />
<div className={styles['label']}>Continue with Apple</div>
</Button>
{
state.form === SIGNUP_FORM ?
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
@ -388,7 +426,7 @@ const Intro = ({ queryParams }) => {
<div className={styles['loader-container']}>
<Icon className={styles['icon']} name={'person'} />
<div className={styles['label']}>Authenticating...</div>
<Button className={styles['button']} onClick={cancelLoginWithFacebook}>
<Button className={styles['button']} onClick={cancelLoginWithFacebook && cancelLoginWithApple}>
{t('BUTTON_CANCEL')}
</Button>
</div>

View file

@ -175,15 +175,43 @@
position: relative;
width: 22rem;
margin-left: 2rem;
display: flex;
flex-direction: column;
.facebook-button {
background: var(--color-facebook);
margin-bottom: 1rem;
&:hover, &:focus {
outline: var(--focus-outline-size) solid var(--color-facebook);
background-color: transparent;
}
}
.apple-button {
background: var(--primary-foreground-color);
.icon {
color: var(--primary-background-color);
}
.label {
color: var(--primary-background-color);
}
&:hover, &:focus {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
.icon {
color: var(--primary-foreground-color);
}
.label {
color: var(--primary-foreground-color);
}
}
}
}
}
}

View file

@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef } from 'react';
import { jwtDecode, JwtPayload } from 'jwt-decode';
type AppleLoginResponse = {
token: string;
sub: string;
email: string;
name: string;
};
type AppleSignInResponse = {
authorization: {
code?: string;
id_token: string;
state?: string;
};
email?: string;
fullName?: {
firstName?: string;
lastName?: string;
};
};
type CustomJWTPayload = JwtPayload & {
email?: string;
};
const CLIENT_ID = 'com.stremio.services';
const useAppleLogin = (): [() => Promise<AppleLoginResponse>, () => void] => {
const started = useRef(false);
const start = useCallback((): Promise<AppleLoginResponse> => {
return new Promise((resolve, reject) => {
if (typeof window.AppleID === 'undefined') {
reject(new Error('Apple Sign-In not loaded'));
return;
}
if (started.current) {
reject(new Error('Apple login already in progress'));
return;
}
started.current = true;
window.AppleID.auth.init({
clientId: CLIENT_ID,
scope: 'name email',
redirectURI: 'https://web.stremio.com/',
state: 'signin',
usePopup: true,
});
window.AppleID.auth.signIn().then((response: AppleSignInResponse) => {
if (response.authorization) {
try {
const idToken = response.authorization.id_token;
const payload: CustomJWTPayload = jwtDecode(idToken);
const sub = payload.sub;
const email = payload.email ?? response.email ?? '';
let name = '';
if (response.fullName) {
const firstName = response.fullName.firstName || '';
const lastName = response.fullName.lastName || '';
name = [firstName, lastName].filter(Boolean).join(' ');
}
if (!sub) {
reject(new Error('No sub token received from Apple'));
return;
}
resolve({
token: idToken,
sub: sub,
email: email,
name: name,
});
} catch (error) {
reject(new Error(`Failed to parse id_token: ${error}`));
}
} else {
reject(new Error('No authorization received from Apple'));
}
}).catch((error) => {
reject(error);
});
});
}, []);
const stop = useCallback(() => {
started.current = false;
}, []);
useEffect(() => {
return () => stop();
}, []);
return [start, stop];
};
export default useAppleLogin;

25
src/types/global.d.ts vendored
View file

@ -26,6 +26,31 @@ interface Chrome {
declare global {
var qt: Qt | undefined;
var chrome: Chrome | undefined;
interface Window {
AppleID: {
auth: {
init: (config: {
clientId: string;
scope: string;
redirectURI: string;
state: string;
usePopup: boolean;
}) => void;
signIn: () => Promise<{
authorization: {
code?: string;
id_token: string;
state?: string;
};
email?: string;
fullName?: {
firstName?: string;
lastName?: string;
};
}>;
};
};
}
}
export {};