diff --git a/package-lock.json b/package-lock.json index b052d8342..7f2e0b955 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f7fc127a1..c12a0b7e8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.html b/src/index.html index ba8a9e795..033f9a267 100644 --- a/src/index.html +++ b/src/index.html @@ -15,6 +15,7 @@
<%= htmlWebpackPlugin.tags.bodyTags %> + \ No newline at end of file diff --git a/src/routes/Intro/Intro.js b/src/routes/Intro/Intro.js index 989e6febd..716fe0f0d 100644 --- a/src/routes/Intro/Intro.js +++ b/src/routes/Intro/Intro.js @@ -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 }) => { } { - state.error.length > 0 ? + state.error && state.error.length > 0 ?
{state.error}
: null @@ -350,6 +384,10 @@ const Intro = ({ queryParams }) => {
Continue with Facebook
+ { state.form === SIGNUP_FORM ? diff --git a/src/routes/Intro/styles.less b/src/routes/Intro/styles.less index 935b0fad1..31a09d54c 100644 --- a/src/routes/Intro/styles.less +++ b/src/routes/Intro/styles.less @@ -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); + } + } + } } } } diff --git a/src/routes/Intro/useAppleLogin.ts b/src/routes/Intro/useAppleLogin.ts new file mode 100644 index 000000000..900944eb8 --- /dev/null +++ b/src/routes/Intro/useAppleLogin.ts @@ -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, () => void] => { + const started = useRef(false); + + const start = useCallback((): Promise => { + 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; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 3849b8914..e55146e9a 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -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 {};