owncast/webroot/js/components/chat/chat-menu.js
Gabe Kangas b835de2dc4
IndieAuth support (#1811)
* Able to authenticate user against IndieAuth. For #1273

* WIP server indieauth endpoint. For https://github.com/owncast/owncast/issues/1272

* Add migration to remove access tokens from user

* Add authenticated bool to user for display purposes

* Add indieauth modal and auth flair to display names. For #1273

* Validate URLs and display errors

* Renames, cleanups

* Handle relative auth endpoint paths. Add error handling for missing redirects.

* Disallow using display names in use by registered users. Closes #1810

* Verify code verifier via code challenge on callback

* Use relative path to authorization_endpoint

* Post-rebase fixes

* Use a timestamp instead of a bool for authenticated

* Propertly handle and display error in modal

* Use auth'ed timestamp to derive authenticated flag to display in chat

* don't redirect unless a URL is present

avoids redirecting to `undefined` if there was an error

* improve error message if owncast server URL isn't set

* fix IndieAuth PKCE implementation

use SHA256 instead of SHA1, generates a longer code verifier (must be 43-128 chars long), fixes URL-safe SHA256 encoding

* return real profile data for IndieAuth response

* check the code verifier in the IndieAuth server

* Linting

* Add new chat settings modal anad split up indieauth ui

* Remove logging error

* Update the IndieAuth modal UI. For #1273

* Add IndieAuth repsonse error checking

* Disable IndieAuth client if server URL is not set.

* Add explicit error messages for specific error types

* Fix bad logic

* Return OAuth-keyed error responses for indieauth server

* Display IndieAuth error in plain text with link to return to main page

* Remove redundant check

* Add additional detail to error

* Hide IndieAuth details behind disclosure details

* Break out migration into two steps because some people have been runing dev in production

* Add auth option to user dropdown

Co-authored-by: Aaron Parecki <aaron@parecki.com>
2022-04-21 14:55:26 -07:00

140 lines
3.9 KiB
JavaScript

import { h, createContext } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import { useState, useEffect, useRef } from '/js/web_modules/preact/hooks.js';
import UsernameForm from './username.js';
import { ChatIcon, UserIcon, CaretDownIcon } from '../icons/index.js';
const html = htm.bind(h);
const moderatorFlag = html`
<img src="/img/moderator-nobackground.svg" class="moderator-flag" />
`;
const Context = createContext();
export const ChatMenu = (props) => {
const {
username,
isModerator,
chatDisabled,
noVideoContent,
handleChatPanelToggle,
onUsernameChange,
showAuthModal,
onFocus,
onBlur,
} = props;
const [chatMenuOpen, setChatMenuOpen] = useState(false);
const [view, setView] = useState('main');
const chatMenuRef = useRef(undefined);
closeOnOutsideClick(chatMenuRef, setChatMenuOpen);
useEffect(() => {
if (chatMenuOpen) setView('main');
}, [chatMenuOpen]);
const authMenuItem =
showAuthModal &&
html`<li>
<button type="button" id="chat-auth" onClick=${showAuthModal}>
Authenticate
<span><${ChatIcon} /></span>
</button>
</li>`;
return html`
<${Context.Provider} value=${props}>
<div class="chat-menu p-2 relative shadow-lg" ref=${chatMenuRef}>
<button
id="chat-menu-button"
class="flex items-center p-1 bg-transparent rounded-md overflow-hidden text-gray-200 transition duration-150"
onClick="${() => setChatMenuOpen(!chatMenuOpen)}"
>
${
!isModerator
? html`<${UserIcon} className="w-6 h-6 mr-2" />`
: moderatorFlag
}
<span
id="username-display"
class="text-indigo-100 text-sm font-bold truncate overflow-hidden whitespace-no-wrap ${
isModerator && 'moderator-flag'
}"
>
${username}
</span>
<${CaretDownIcon} className="w-8 h-8"/>
</button>
${
chatMenuOpen &&
html` <div
class="chat-menu-popout shadow-2xl text-gray-100 absolute w-max top-full right-0 z-50 rounded-md p-2 bg-gray-900 fadeIn "
style=${{ minWidth: '20rem' }}
>
${view === 'main' &&
html`<ul class="chat-menu-options w-max">
<li>
<${UsernameForm}
username=${username}
isModerator=${isModerator}
onUsernameChange=${onUsernameChange}
onFocus=${onFocus}
onBlur=${onBlur}
/>
</li>
${authMenuItem}
<li>
<button
type="button"
id="chat-toggle"
onClick=${handleChatPanelToggle}
style=${{
display: chatDisabled || noVideoContent ? 'none' : 'flex',
}}
>
<span>Toggle Chat</span>
<span><${ChatIcon} /></span>
</button>
</li>
</ul>`}
${view != 'main' &&
html`<${SubMenuView} view=${view} setView=${setView} />`}
</div>`
}
</div>
</${Context.Provider}>`;
};
const SubMenuView = ({ view, setView }) => {
return html`
<div className=${`chat-view fadeInRight`}>
<ul>
<li>
<button onClick=${() => setView('main')}>
<span
><${CaretDownIcon} className="w-6 h-6 transform rotate-90"
/></span>
<span>Back</span>
</button>
</li>
</ul>
${view === 'change_username' && html`<${ChangeUsernameView} />`}
</div>
`;
};
function closeOnOutsideClick(ref, setter) {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
setter(undefined);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref]);
}