mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 17:15:48 +00:00
Merge pull request #889 from Stremio/feat/example-apple-login
App: Implement Apple login
This commit is contained in:
commit
a8931d94df
7 changed files with 214 additions and 7 deletions
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -12,7 +12,7 @@
|
||||||
"@babel/runtime": "7.26.0",
|
"@babel/runtime": "7.26.0",
|
||||||
"@sentry/browser": "8.42.0",
|
"@sentry/browser": "8.42.0",
|
||||||
"@stremio/stremio-colors": "5.2.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-icons": "5.4.1",
|
||||||
"@stremio/stremio-video": "0.0.60",
|
"@stremio/stremio-video": "0.0.60",
|
||||||
"a-color-picker": "1.2.1",
|
"a-color-picker": "1.2.1",
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"filter-invalid-dom-props": "3.0.1",
|
"filter-invalid-dom-props": "3.0.1",
|
||||||
"hat": "^0.0.3",
|
"hat": "^0.0.3",
|
||||||
"i18next": "^24.0.5",
|
"i18next": "^24.0.5",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"langs": "github:Stremio/nodejs-langs",
|
"langs": "github:Stremio/nodejs-langs",
|
||||||
"lodash.debounce": "4.0.8",
|
"lodash.debounce": "4.0.8",
|
||||||
"lodash.intersection": "4.4.0",
|
"lodash.intersection": "4.4.0",
|
||||||
|
|
@ -3371,9 +3372,9 @@
|
||||||
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
|
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
|
||||||
},
|
},
|
||||||
"node_modules/@stremio/stremio-core-web": {
|
"node_modules/@stremio/stremio-core-web": {
|
||||||
"version": "0.49.2",
|
"version": "0.49.3",
|
||||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.2.tgz",
|
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.3.tgz",
|
||||||
"integrity": "sha512-IYU+pdHkq4iEfqZ9G+DFZheIE53nY8XyhI1OJLvZp68/4ntRwssXwfj9InHK2Wau20fH+oV2KD1ZWb0CsTLqPA==",
|
"integrity": "sha512-Ql/08LbwU99IUL6fOLy+v1Iv75boHXpunEPScKgXJALdq/OV5tZLG/IycN0O+5+50Nc/NHrI6HslnMNLTWA8JQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.24.1"
|
"@babel/runtime": "7.24.1"
|
||||||
|
|
@ -10234,6 +10235,15 @@
|
||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"@babel/runtime": "7.26.0",
|
"@babel/runtime": "7.26.0",
|
||||||
"@sentry/browser": "8.42.0",
|
"@sentry/browser": "8.42.0",
|
||||||
"@stremio/stremio-colors": "5.2.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-icons": "5.4.1",
|
||||||
"@stremio/stremio-video": "0.0.60",
|
"@stremio/stremio-video": "0.0.60",
|
||||||
"a-color-picker": "1.2.1",
|
"a-color-picker": "1.2.1",
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"filter-invalid-dom-props": "3.0.1",
|
"filter-invalid-dom-props": "3.0.1",
|
||||||
"hat": "^0.0.3",
|
"hat": "^0.0.3",
|
||||||
"i18next": "^24.0.5",
|
"i18next": "^24.0.5",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"langs": "github:Stremio/nodejs-langs",
|
"langs": "github:Stremio/nodejs-langs",
|
||||||
"lodash.debounce": "4.0.8",
|
"lodash.debounce": "4.0.8",
|
||||||
"lodash.intersection": "4.4.0",
|
"lodash.intersection": "4.4.0",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<%= htmlWebpackPlugin.tags.bodyTags %>
|
<%= htmlWebpackPlugin.tags.bodyTags %>
|
||||||
<script src="//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
<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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -12,6 +12,8 @@ const { Button, Image, Checkbox } = require('stremio/components');
|
||||||
const CredentialsTextInput = require('./CredentialsTextInput');
|
const CredentialsTextInput = require('./CredentialsTextInput');
|
||||||
const PasswordResetModal = require('./PasswordResetModal');
|
const PasswordResetModal = require('./PasswordResetModal');
|
||||||
const useFacebookLogin = require('./useFacebookLogin');
|
const useFacebookLogin = require('./useFacebookLogin');
|
||||||
|
const { default: useAppleLogin } = require('./useAppleLogin');
|
||||||
|
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
const SIGNUP_FORM = 'signup';
|
const SIGNUP_FORM = 'signup';
|
||||||
|
|
@ -22,6 +24,7 @@ const Intro = ({ queryParams }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const routeFocused = useRouteFocused();
|
const routeFocused = useRouteFocused();
|
||||||
const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin();
|
const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin();
|
||||||
|
const [startAppleLogin, stopAppleLogin] = useAppleLogin();
|
||||||
const emailRef = React.useRef(null);
|
const emailRef = React.useRef(null);
|
||||||
const passwordRef = React.useRef(null);
|
const passwordRef = React.useRef(null);
|
||||||
const confirmPasswordRef = React.useRef(null);
|
const confirmPasswordRef = React.useRef(null);
|
||||||
|
|
@ -106,6 +109,37 @@ const Intro = ({ queryParams }) => {
|
||||||
stopFacebookLogin();
|
stopFacebookLogin();
|
||||||
closeLoaderModal();
|
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(() => {
|
const loginWithEmail = React.useCallback(() => {
|
||||||
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
||||||
dispatch({ type: 'error', error: 'Invalid email' });
|
dispatch({ type: 'error', error: 'Invalid email' });
|
||||||
|
|
@ -336,7 +370,7 @@ const Intro = ({ queryParams }) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
state.error.length > 0 ?
|
state.error && state.error.length > 0 ?
|
||||||
<div ref={errorRef} className={styles['error-message']}>{state.error}</div>
|
<div ref={errorRef} className={styles['error-message']}>{state.error}</div>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
|
|
@ -350,6 +384,10 @@ const Intro = ({ queryParams }) => {
|
||||||
<Icon className={styles['icon']} name={'facebook'} />
|
<Icon className={styles['icon']} name={'facebook'} />
|
||||||
<div className={styles['label']}>Continue with Facebook</div>
|
<div className={styles['label']}>Continue with Facebook</div>
|
||||||
</Button>
|
</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 ?
|
state.form === SIGNUP_FORM ?
|
||||||
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
|
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
|
||||||
|
|
@ -388,7 +426,7 @@ const Intro = ({ queryParams }) => {
|
||||||
<div className={styles['loader-container']}>
|
<div className={styles['loader-container']}>
|
||||||
<Icon className={styles['icon']} name={'person'} />
|
<Icon className={styles['icon']} name={'person'} />
|
||||||
<div className={styles['label']}>Authenticating...</div>
|
<div className={styles['label']}>Authenticating...</div>
|
||||||
<Button className={styles['button']} onClick={cancelLoginWithFacebook}>
|
<Button className={styles['button']} onClick={cancelLoginWithFacebook && cancelLoginWithApple}>
|
||||||
{t('BUTTON_CANCEL')}
|
{t('BUTTON_CANCEL')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -175,15 +175,43 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 22rem;
|
width: 22rem;
|
||||||
margin-left: 2rem;
|
margin-left: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.facebook-button {
|
.facebook-button {
|
||||||
background: var(--color-facebook);
|
background: var(--color-facebook);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
outline: var(--focus-outline-size) solid var(--color-facebook);
|
outline: var(--focus-outline-size) solid var(--color-facebook);
|
||||||
background-color: transparent;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
src/routes/Intro/useAppleLogin.ts
Normal file
104
src/routes/Intro/useAppleLogin.ts
Normal 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
25
src/types/global.d.ts
vendored
|
|
@ -26,6 +26,31 @@ interface Chrome {
|
||||||
declare global {
|
declare global {
|
||||||
var qt: Qt | undefined;
|
var qt: Qt | undefined;
|
||||||
var chrome: Chrome | 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 {};
|
export {};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue