This commit is contained in:
Maxime Deprince 2026-01-11 11:18:55 +00:00 committed by GitHub
commit 74153b0f2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 670 additions and 23 deletions

View file

@ -15,7 +15,9 @@ const withProtectedRoutes = (Component) => {
previousAuthRef.current = profile.auth;
}, [profile]);
const onRouteChange = React.useCallback((routeConfig) => {
if (profile.auth !== null && routeConfig.component === Intro) {
// Allow access to /intro when ?profiles=true (for profile switching)
const urlParams = new URLSearchParams(window.location.hash.split('?')[1]);
if (profile.auth !== null && routeConfig.component === Intro && urlParams.get('profiles') !== 'true') {
window.location.replace('#/');
return true;
}

View file

@ -0,0 +1,138 @@
// Copyright (C) 2017-2023 Smart code 203358507
/**
* Storage key used for storing multi-account data in localStorage.
* @constant {string}
*/
const STORAGE_KEY = 'stremio-multi-accounts';
/**
* AccountManager module for managing multiple Stremio accounts.
* Stores account data in localStorage including email, avatar, authKey, and settings.
*
* @warning authKeys are stored in localStorage. While convenient for multi-account
* switching, this has security implications - anyone with access to the browser's
* localStorage can retrieve these tokens.
*
* @typedef {Object} Account
* @property {string} email - User's email address (unique identifier)
* @property {string} avatar - URL to user's avatar image
* @property {string} authKey - Authentication token for the account
* @property {Object|null} settings - User's application settings
* @property {number} lastActive - Timestamp of last account activity
*/
const AccountManager = {
/**
* Retrieves all stored accounts from localStorage.
* @returns {Account[]} Array of account objects, empty array if none or on error
*/
getAccounts: () => {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
} catch (_e) {
return [];
}
},
/**
* Adds or updates an account in storage.
* If account with same email exists, it will be updated.
* @param {Object} profile - User profile object
* @param {string} profile.email - User's email
* @param {string} profile.avatar - User's avatar URL
* @param {string} authKey - Authentication token
* @param {Object|null} [settings=null] - Optional settings to store
*/
addAccount: (profile, authKey, settings = null) => {
try {
const accounts = AccountManager.getAccounts();
const existing = accounts.find((a) => a.email === profile.email);
const filtered = accounts.filter((a) => a.email !== profile.email);
const newAccount = {
email: profile.email,
avatar: profile.avatar,
authKey: authKey,
settings: settings || existing?.settings || null,
lastActive: Date.now()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify([...filtered, newAccount]));
} catch (_e) {
// localStorage may be full or disabled
}
},
/**
* Updates settings for a specific account.
* @param {string} email - Email of account to update
* @param {Object} settings - New settings object
*/
updateSettings: (email, settings) => {
try {
const accounts = AccountManager.getAccounts();
const updated = accounts.map((a) =>
a.email === email ? { ...a, settings } : a
);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch (_e) {
// localStorage may be full or disabled
}
},
/**
* Retrieves settings for a specific account.
* @param {string} email - Email of account
* @returns {Object|null} Settings object or null if not found
*/
getSettings: (email) => {
const accounts = AccountManager.getAccounts();
const account = accounts.find((a) => a.email === email);
return account?.settings || null;
},
/**
* Removes an account from storage.
* @param {string} email - Email of account to remove
*/
removeAccount: (email) => {
try {
const accounts = AccountManager.getAccounts();
const filtered = accounts.filter((a) => a.email !== email);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
} catch (_e) {
// localStorage may be full or disabled
}
},
/**
* Switches to a specific account, updating lastActive timestamp.
* Uses immutable map operation to avoid mutating the original array.
* @param {string} email - Email of account to switch to
* @returns {Account|null} The account object if found and has authKey, null otherwise
*/
switchTo: (email) => {
const accounts = AccountManager.getAccounts();
const target = accounts.find((a) => a.email === email);
if (target && target.authKey) {
let updatedTarget = target;
const updatedAccounts = accounts.map((a) => {
if (a.email === email) {
const newAccount = { ...a, lastActive: Date.now() };
updatedTarget = newAccount;
return newAccount;
}
return a;
});
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedAccounts));
} catch (_e) {
// localStorage may be full or disabled
}
return updatedTarget;
}
return null;
}
};
module.exports = AccountManager;

View file

@ -5,6 +5,7 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const AccountManager = require('stremio/common/AccountManager');
const { useServices } = require('stremio/services');
const { Button } = require('stremio/components');
const { default: useFullscreen } = require('stremio/common/useFullscreen');
@ -29,14 +30,32 @@ const NavMenuContent = ({ onClick }) => {
profile.settings.streamingServerWarningDismissed.getTime() > Date.now()
);
}, [profile.settings, streamingServer.settings]);
const onChangeProfile = React.useCallback(() => {
if (profile.auth && profile.auth.user && profile.auth.key) {
AccountManager.addAccount(profile.auth.user, profile.auth.key, profile.settings);
}
window.location.hash = '#/intro?profiles=true';
}, [profile.auth, profile.settings]);
const logoutButtonOnClick = React.useCallback(() => {
const currentEmail = profile.auth?.user?.email;
const accounts = AccountManager.getAccounts();
const remainingAccounts = accounts.filter((acc) => acc.email !== currentEmail);
if (currentEmail) {
AccountManager.removeAccount(currentEmail);
}
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'Logout'
}
});
}, []);
if (remainingAccounts.length > 0) {
window.location.hash = '#/intro?profiles=true';
}
}, [profile.auth, core]);
const onPlayMagnetLinkClick = React.useCallback(async () => {
try {
const clipboardText = await navigator.clipboard.readText();
@ -64,9 +83,25 @@ const NavMenuContent = ({ onClick }) => {
<div className={styles['email-container']}>
<div className={styles['email-label']}>{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}</div>
</div>
<Button className={styles['logout-button-container']} title={profile.auth === null ? `${t('LOG_IN')} / ${t('SIGN_UP')}` : t('LOG_OUT')} href={profile.auth === null ? '#/intro' : null} onClick={profile.auth !== null ? logoutButtonOnClick : null}>
<div className={styles['logout-label']}>{profile.auth === null ? `${t('LOG_IN')} / ${t('SIGN_UP')}` : t('LOG_OUT')}</div>
</Button>
<div className={styles['user-actions-container']}>
{
profile.auth !== null ?
<Button className={styles['user-action-button']} title={t('CHANGE_PROFILE', { defaultValue: 'Change Profile' })} onClick={onChangeProfile}>
<div className={styles['user-action-label']}>{t('CHANGE_PROFILE', { defaultValue: 'Change Profile' })}</div>
</Button>
:
null
}
{
profile.auth !== null ?
<div className={styles['user-actions-separator']}>/</div>
:
null
}
<Button className={styles['user-action-button']} title={profile.auth === null ? `${t('LOG_IN')} / ${t('SIGN_UP')}` : t('LOG_OUT')} href={profile.auth === null ? '#/intro' : null} onClick={profile.auth !== null ? logoutButtonOnClick : null}>
<div className={styles['user-action-label']}>{profile.auth === null ? `${t('LOG_IN')} / ${t('SIGN_UP')}` : t('LOG_OUT')}</div>
</Button>
</div>
</div>
</div>
{

View file

@ -61,22 +61,35 @@
}
}
.logout-button-container {
.user-actions-container {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
&:hover, &:focus {
outline: none;
.logout-label {
text-decoration: underline;
}
.user-actions-separator {
color: var(--primary-foreground-color);
opacity: 0.5;
font-size: 0.9rem;
}
.logout-label {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
color: var(--primary-foreground-color);
.user-action-button {
flex: none;
&:hover, &:focus {
outline: none;
.user-action-label {
text-decoration: underline;
}
}
.user-action-label {
font-size: 0.9rem;
font-weight: 500;
color: var(--primary-foreground-color);
}
}
}
}

View file

@ -0,0 +1,145 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { Image } = require('stremio/components');
const styles = require('./styles.less');
const ProfileItem = ({ profile, isEditing, onSelect, onDelete }) => {
const { t } = useTranslation();
const displayName = profile.email
? profile.email.split('@')[0]
: t('USER', { defaultValue: 'User' });
const handleClick = React.useCallback((event) => {
event.stopPropagation();
if (!isEditing) {
onSelect(profile);
}
}, [isEditing, onSelect, profile]);
const handleDelete = React.useCallback((event) => {
event.stopPropagation();
onDelete(profile);
}, [onDelete, profile]);
return (
<button
type="button"
className={classnames(styles['profile-item'], { [styles['editing']]: isEditing })}
onClick={handleClick}
>
<div className={styles['avatar-wrapper']}>
<Image
className={styles['avatar']}
src={profile.avatar || require('/images/default_avatar.png')}
alt={profile.email}
/>
{isEditing && (
<button
className={styles['delete-button']}
onClick={handleDelete}
title={t('DELETE_PROFILE', { defaultValue: 'Delete profile' })}
>
<Icon className={styles['delete-icon']} name={'close'} />
</button>
)}
</div>
<span className={styles['profile-name']}>{displayName}</span>
</button>
);
};
ProfileItem.propTypes = {
profile: PropTypes.shape({
email: PropTypes.string,
avatar: PropTypes.string,
authKey: PropTypes.string,
settings: PropTypes.object,
lastActive: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number
])
}).isRequired,
isEditing: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired
};
const AddProfileButton = ({ onClick }) => {
return (
<button type="button" className={styles['profile-item']} onClick={onClick}>
<div className={classnames(styles['avatar-wrapper'], styles['add-profile-wrapper'])}>
<Icon className={styles['add-icon']} name={'add'} />
</div>
</button>
);
};
AddProfileButton.propTypes = {
onClick: PropTypes.func.isRequired
};
const ProfilePicker = ({ profiles = [], onSelectProfile, onAddProfile, onDeleteProfile }) => {
const { t } = useTranslation();
const [isEditing, setIsEditing] = React.useState(false);
const toggleEditMode = React.useCallback(() => {
setIsEditing((prev) => !prev);
}, []);
const handleDelete = React.useCallback((profile) => {
if (onDeleteProfile) {
onDeleteProfile(profile);
}
}, [onDeleteProfile]);
const title = isEditing
? t('MANAGE_PROFILES', { defaultValue: 'Manage Profiles' })
: t('WHO_IS_WATCHING', { defaultValue: 'Who is watching?' });
const buttonText = isEditing
? t('DONE', { defaultValue: 'DONE' })
: t('MANAGE_PROFILES', { defaultValue: 'MANAGE PROFILES' });
return (
<div className={styles['profile-picker-overlay']}>
<div className={styles['background-container']} />
<div className={styles['profile-picker-container']}>
<h1 className={styles['title']}>{title}</h1>
<div className={styles['profiles-grid']}>
{profiles.map((profile, index) => (
<ProfileItem
key={profile.email || index}
profile={profile}
isEditing={isEditing}
onSelect={onSelectProfile}
onDelete={handleDelete}
/>
))}
{!isEditing && <AddProfileButton onClick={onAddProfile} />}
</div>
<button
className={classnames(styles['manage-button'], { [styles['done-button']]: isEditing })}
onClick={toggleEditMode}
>
{buttonText}
</button>
</div>
</div>
);
};
ProfilePicker.propTypes = {
profiles: PropTypes.array,
onSelectProfile: PropTypes.func,
onAddProfile: PropTypes.func,
onDeleteProfile: PropTypes.func
};
module.exports = ProfilePicker;

View file

@ -0,0 +1,232 @@
// Copyright (C) 2017-2023 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.profile-picker-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
font-family: 'PlusJakartaSans', sans-serif;
user-select: none;
overflow-y: auto;
}
.background-container {
z-index: -1;
position: fixed;
top: -1rem;
bottom: -1rem;
left: -1rem;
right: -1rem;
background: url('/images/background_1.svg'), url('/images/background_2.svg');
background-color: var(--primary-background-color);
background-position: bottom left, top right;
background-size: 53%, 54%;
background-repeat: no-repeat;
filter: blur(6rem);
}
.profile-picker-container {
text-align: center;
animation: fadeIn 0.4s ease-in-out;
overflow: visible;
}
.title {
color: var(--primary-foreground-color);
font-size: 3rem;
font-weight: 600;
margin-bottom: 2rem;
user-select: none;
transition: all 0.3s ease;
}
.profiles-grid {
display: flex;
gap: 2rem;
justify-content: center;
flex-wrap: wrap;
min-height: 200px;
overflow: visible;
}
.profile-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
pointer-events: auto;
transition: transform 0.2s ease;
overflow: visible;
padding: 10px;
background: transparent;
border: none;
font-family: inherit;
&:hover {
.avatar-wrapper {
border-color: var(--primary-foreground-color);
transform: scale(1.05);
}
.profile-name {
color: var(--primary-foreground-color);
}
}
&:active {
.avatar-wrapper {
transform: scale(0.98);
}
}
&.editing {
cursor: default;
&:hover {
.avatar-wrapper {
transform: none;
border-color: transparent;
}
}
.avatar-wrapper {
opacity: 0.7;
}
}
}
.avatar-wrapper {
width: 10vw;
height: 10vw;
min-width: 84px;
min-height: 84px;
max-width: 200px;
max-height: 200px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 1rem;
border: 3px solid transparent;
transition: border-color 0.2s, transform 0.2s, opacity 0.2s;
position: relative;
&.add-profile-wrapper {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--overlay-color);
border: 3px solid var(--secondary-foreground-color);
&:hover {
background-color: var(--primary-foreground-color);
border-color: var(--primary-foreground-color);
.add-icon {
fill: var(--primary-background-color);
}
}
}
}
.avatar {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
border-radius: 50%;
}
.delete-button {
position: absolute;
top: -8px;
right: -8px;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--signal-error-color);
border: 2px solid var(--primary-background-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, background-color 0.2s ease;
z-index: 10;
padding: 0;
&:hover {
transform: scale(1.15);
}
&:active {
transform: scale(0.95);
}
}
.delete-icon {
width: 16px;
height: 16px;
fill: var(--primary-foreground-color);
pointer-events: none;
}
.add-icon {
width: 50%;
height: 50%;
fill: var(--primary-foreground-color);
transition: fill 0.2s;
pointer-events: none;
}
.profile-name {
color: var(--primary-foreground-color);
font-size: 1.2rem;
font-weight: 500;
transition: color 0.2s;
max-width: 12vw;
min-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
.manage-button {
margin-top: 3rem;
background: transparent;
border: 1px solid var(--primary-foreground-color);
color: var(--primary-foreground-color);
padding: 0.5rem 1.5rem;
font-size: 1.1rem;
letter-spacing: 2px;
cursor: pointer;
display: inline-block;
user-select: none;
transition: all 0.2s ease;
border-radius: 3.5rem;
opacity: 0.7;
&:hover {
opacity: 1;
}
&.done-button {
background-color: var(--primary-accent-color);
color: var(--primary-foreground-color);
border-color: var(--primary-accent-color);
opacity: 1;
&:hover {
opacity: 0.9;
}
}
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(1.1); }
to { opacity: 1; transform: scale(1); }
}

View file

@ -15,6 +15,8 @@ const useFacebookLogin = require('./useFacebookLogin');
const { default: useAppleLogin } = require('./useAppleLogin');
const styles = require('./styles');
const AccountManager = require('stremio/common/AccountManager');
const ProfilePicker = require('stremio/components/ProfilePicker/ProfilePicker');
const SIGNUP_FORM = 'signup';
const LOGIN_FORM = 'login';
@ -249,8 +251,10 @@ const Intro = ({ queryParams }) => {
dispatch({ type: 'toggle-checkbox', name: 'marketingAccepted' });
}, []);
const switchFormOnClick = React.useCallback(() => {
const queryParams = new URLSearchParams([['form', state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM]]);
window.location = `#/intro?${queryParams.toString()}`;
const newForm = state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM;
const currentParams = new URLSearchParams(window.location.hash.split('?')[1]);
currentParams.set('form', newForm);
window.location = `#/intro?${currentParams.toString()}`;
}, [state.form]);
React.useEffect(() => {
if ([LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form'))) {
@ -262,15 +266,32 @@ const Intro = ({ queryParams }) => {
errorRef.current.scrollIntoView();
}
}, [state.error]);
const [savedAccounts, setSavedAccounts] = React.useState(() => AccountManager.getAccounts());
const [pendingProfile, setPendingProfile] = React.useState(null);
const showProfilesParam = queryParams.get('profiles') === 'true';
const [showProfiles, setShowProfiles] = React.useState(showProfilesParam || savedAccounts.length > 0);
React.useEffect(() => {
if (routeFocused) {
if (routeFocused && !showProfiles && emailRef.current) {
emailRef.current.focus();
}
}, [state.form, routeFocused]);
}, [state.form, routeFocused, showProfiles]);
React.useEffect(() => {
const onCoreEvent = ({ event, args }) => {
switch (event) {
case 'UserAuthenticated': {
if (args.auth && args.auth.key) {
AccountManager.addAccount(args.user, args.auth.key);
}
if (pendingProfile && pendingProfile.settings) {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: pendingProfile.settings
}
});
}
setPendingProfile(null);
closeLoaderModal();
if (routeFocused) {
window.location = '#/';
@ -280,8 +301,23 @@ const Intro = ({ queryParams }) => {
case 'Error': {
if (args.source.event === 'UserAuthenticated') {
closeLoaderModal();
if (pendingProfile) {
AccountManager.removeAccount(pendingProfile.email);
setSavedAccounts(AccountManager.getAccounts());
dispatch({ type: 'set-form', form: LOGIN_FORM });
dispatch({
type: 'error',
error: t('SESSION_EXPIRED', { defaultValue: 'Session expired. Please log in again.' })
});
dispatch({
type: 'change-credentials',
name: 'email',
value: pendingProfile.email
});
setShowProfiles(false);
setPendingProfile(null);
}
}
break;
}
}
@ -290,7 +326,53 @@ const Intro = ({ queryParams }) => {
return () => {
core.transport.off('CoreEvent', onCoreEvent);
};
}, [routeFocused]);
}, [routeFocused, pendingProfile, t, closeLoaderModal, dispatch, core]);
const onSelectProfile = React.useCallback((profile) => {
if (pendingProfile) return;
const account = AccountManager.switchTo(profile.email);
if (account && account.authKey) {
setPendingProfile({ ...profile, settings: account.settings });
openLoaderModal();
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'Authenticate',
args: {
type: 'LoginWithToken',
token: account.authKey
}
}
});
} else {
dispatch({
type: 'change-credentials',
name: 'email',
value: profile.email
});
setShowProfiles(false);
}
}, [core, openLoaderModal, pendingProfile, dispatch]);
const onAddProfile = React.useCallback(() => {
setShowProfiles(false);
}, []);
const onDeleteProfile = React.useCallback((profile) => {
AccountManager.removeAccount(profile.email);
const updatedAccounts = AccountManager.getAccounts();
setSavedAccounts(updatedAccounts);
if (updatedAccounts.length === 0) {
setShowProfiles(false);
}
}, []);
if (showProfiles && savedAccounts.length > 0) {
return (
<ProfilePicker
profiles={savedAccounts}
onSelectProfile={onSelectProfile}
onAddProfile={onAddProfile}
onDeleteProfile={onDeleteProfile}
/>
);
}
return (
<div className={styles['intro-container']}>
<div className={styles['background-container']} />