import { AccountRef, ProjectRef } from '@vertesia/common' import { Button, Center, ErrorBox, Input, SelectBox, Spinner, useFetch, useToast } from '@vertesia/ui/core' import { Env } from "@vertesia/ui/env" import { useLocation } from "@vertesia/ui/router" import { fetchComposableTokenFromFirebaseToken, useUserSession } from '@vertesia/ui/session' import { useState } from 'react' import { useUITranslation } from '../../i18n/index.js' interface ProfileData { profile?: string account?: string project?: string } interface LoginResult extends Required { token: string studio_server_url: string zeno_server_url: string } interface ClientInfo extends ProfileData { redirect: string code: string } const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost']) function parseRedirectUri(rawRedirect: string | null): string | null { if (!rawRedirect) { return null } let decoded: string try { decoded = decodeURIComponent(rawRedirect) } catch { return null } let parsed: URL try { parsed = new URL(decoded) } catch { return null } if (parsed.protocol !== 'http:') { return null } if (!parsed.port) { return null } if (parsed.username || parsed.password) { return null } if (!LOOPBACK_HOSTS.has(parsed.hostname)) { return null } return parsed.toString() } function getClientInfo(location: Location): ClientInfo | null { const params = new URLSearchParams(location.search) const redirect = parseRedirectUri(params.get('redirect_uri')) const code = params.get('code') if (!redirect || !code) { return null } const profile = params.get('profile') ?? "default" const project = params.get('project') ?? undefined const account = params.get('account') ?? undefined return { redirect, code, profile, project, account } } export function TerminalLogin() { const [payload, setPayload] = useState() const [error, setError] = useState() const location = useLocation() const clientInfo = getClientInfo(location) const toast = useToast() const { t } = useUITranslation() const onAccept = async (data: ProfileData) => { if (!clientInfo) return if (!data.profile) { toast({ title: t('login.terminal.profileRequired'), description: t('login.terminal.profileRequiredDesc'), status: 'error', duration: 2000 }) return } if (!data.account) { toast({ title: t('login.terminal.accountRequired'), description: t('login.terminal.accountRequiredDesc'), status: 'error', duration: 2000 }) return } if (!data.project) { toast({ title: t('login.terminal.projectRequired'), description: t('login.terminal.projectRequiredDesc'), status: 'error', duration: 2000 }) return } // expire in 1 day let payload: LoginResult | undefined try { const token = await fetchComposableTokenFromFirebaseToken(data.account, data.project, 24 * 3600) if (token) { payload = { ...data, studio_server_url: Env.endpoints.studio, zeno_server_url: Env.endpoints.zeno, token, } as LoginResult await fetch(clientInfo.redirect, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) setPayload(payload) } else { toast({ title: t('login.terminal.failedToGetToken'), status: 'error', duration: 5000 }) } } catch (err: any) { if (payload) { setError(err) setPayload(payload) } else { toast({ title: t('login.terminal.errorAuthorizingClient'), description: err.message, status: 'error', duration: 5000 }) } } } const getPageContent = () => { if (!clientInfo) { return {t('login.terminal.invalidRequestDesc')} } return payload ? : } const page = getPageContent() return (
{page}
) } interface AuthAcceptScreenProps { onAccept: (data: ProfileData) => void clientInfo: ClientInfo } function AuthAcceptScreen({ onAccept, clientInfo }: Readonly) { const { client, user } = useUserSession() const { data: allProjects, error } = useFetch(() => user ? client.projects.list() : Promise.resolve([]), [user]) const { t } = useUITranslation() if (error) { return {error.message} } const getEnvironmentName = () => { if (Env.isLocalDev) { return t('login.terminal.envLocalDev') } else if (Env.isDev) { return t('login.terminal.envStaging') } return t('login.terminal.envProduction') } const envName = getEnvironmentName() return user && allProjects ? ( <>
Authorizing client on {envName} environment.
{t('login.terminal.clientWantsAuth')}
The client app code is {clientInfo.code}. You can check if the code is correct in the terminal.
{t('login.terminal.chooseAccountProject')}
{t('login.terminal.profileNameNote')}
) : } function AuthDoneScreen({ payload, error }: Readonly<{ payload: LoginResult, error?: Error }>) { const toast = useToast() const { t } = useUITranslation() const onCopy = () => { if (payload) { navigator.clipboard.writeText(JSON.stringify(payload)) toast({ title: t('login.terminal.authPayloadCopied'), description: error ? t('login.terminal.authPayloadCopiedWithError', { error: error.message }) : t('login.terminal.authPayloadCopiedSuccess'), status: 'success', duration: 5000 }) } } return (
{ error ?
{t('login.terminal.failedToSendTokenDesc', { error: error.message })}
:
{t('login.terminal.clientAuthenticated')}
}
) } interface ProfileFormProps { onAccept: (data: ProfileData) => void allProjects: ProjectRef[] data: ProfileData } function ProfileForm({ allProjects, data, onAccept }: Readonly) { const { accounts, account, project } = useUserSession() const { t } = useUITranslation() const [currentData, setCurrentData] = useState(() => ({ profile: data.profile, account: data.account ?? account?.id, project: data.project ?? project?.id, })) const onChangeProfile = (value: string) => { setCurrentData({ ...currentData, profile: value }) } const onChangeAccount = (value: AccountRef) => { setCurrentData({ ...currentData, account: value.id, project: undefined }) } const onChangeProject = (value: ProjectRef) => { setCurrentData({ ...currentData, project: value.id }) } const projects = allProjects.filter(p => p.account === currentData.account) return (
{t('login.terminal.profileName')}
{t('login.terminal.account')}
{t('login.terminal.project')}
{t('login.terminal.browserPermissionNote')}
) } interface SelectAccountProps { value?: string accounts: AccountRef[] onChange: (value: AccountRef) => void } function SelectAccount({ value, accounts, onChange }: Readonly) { const { t } = useUITranslation() const _onChange = (value: AccountRef) => { onChange(value) } return a.id === value)} onChange={_onChange} by="id" optionLabel={(option) => option.name} placeholder={t('login.terminal.selectAccount')} /> } interface SelectProjectProps { value?: string projects: ProjectRef[] onChange: (value: ProjectRef) => void } function SelectProject({ value, projects, onChange }: Readonly) { const { t } = useUITranslation() const _onChange = (value: ProjectRef) => { onChange(value) } return ( p.id === value)} options={projects} optionLabel={(option) => option.name} placeholder={t('login.terminal.selectProject')} onChange={_onChange} /> ) }