diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index 1bf9828da3..e5a082d0e8 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -9,7 +9,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import axios from 'axios' import cors from 'cors' -import type { Request, Response } from 'express' +import type { CookieOptions, Request, Response } from 'express' import express from 'express' import * as jwt from 'jsonwebtoken' import url from 'url' @@ -47,7 +47,9 @@ import { validateGoogleUser, } from './google_auth' import { createWebAuthToken } from './jwt_helpers' -import { createMobileAccountCreationResponse } from './mobile/account_creation' +import { createAccountCreationResponse } from '../../services/account_creation' +import { libraryItemRepository } from '../../repository/library_item' +import { Claims } from '../../resolvers/types' export interface SignupRequest { email: string @@ -61,8 +63,10 @@ export interface SignupRequest { const signToken = promisify(jwt.sign) -const cookieParams = { +const cookieParams: CookieOptions = { httpOnly: true, + sameSite: 'strict', + secure: true, maxAge: 365 * 24 * 60 * 60 * 1000, } @@ -122,23 +126,39 @@ export function authRouter() { hourlyLimiter, cors(corsConfig), async (req, res) => { + const WELCOME_TO_OMNIVORE_SLUG = 'welcome-to-omnivore' const { name, bio, username } = req.body // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const token = req.cookies?.pendingUserAuth as string | undefined - const payload = await createMobileAccountCreationResponse(token, { + const payload = await createAccountCreationResponse(token, { name, username, bio, }) + let hasGettingStarted = false + if (payload.json.authToken) { + const { uid } = jwt.decode(payload.json.authToken) as Claims + hasGettingStarted = + uid != null && + (await libraryItemRepository.count({ + where: { user: { id: uid }, slug: WELCOME_TO_OMNIVORE_SLUG }, + })) > 0 + } + if (payload.json.authToken) { res.cookie('auth', payload.json.authToken, cookieParams) res.clearCookie('pendingUserAuth') } - res.status(payload.statusCode).json({}) + const redirect = hasGettingStarted + ? `/${username}/${WELCOME_TO_OMNIVORE_SLUG}` + : '/home' + res.status(payload.statusCode).json({ + redirect: redirect, + }) } ) diff --git a/packages/api/src/routers/auth/google_auth.ts b/packages/api/src/routers/auth/google_auth.ts index 1108b6f235..b723947390 100644 --- a/packages/api/src/routers/auth/google_auth.ts +++ b/packages/api/src/routers/auth/google_auth.ts @@ -161,7 +161,7 @@ export async function handleGoogleWebAuth( if (authToken) { const ssoToken = createSsoToken(authToken, `${baseURL()}/home`) const redirectURL = isVercel - ? ssoRedirectURL(ssoToken) + ? ssoRedirectURL(ssoToken, `${baseURL()}/home`) : `${baseURL()}/home` return { diff --git a/packages/api/src/routers/auth/jwt_helpers.ts b/packages/api/src/routers/auth/jwt_helpers.ts index 941982f54a..f8adbb472a 100644 --- a/packages/api/src/routers/auth/jwt_helpers.ts +++ b/packages/api/src/routers/auth/jwt_helpers.ts @@ -11,7 +11,7 @@ import { const signToken = promisify(jwt.sign) -type MobileAuthPayload = { +type AuthPayload = { authToken: string authCookieString: string } @@ -27,9 +27,7 @@ export async function createWebAuthToken( } } -export async function createMobileAuthPayload( - userId: string -): Promise { +export async function createAuthPayload(userId: string): Promise { const authToken = await signToken({ uid: userId }, env.server.jwtSecret) const authCookieString = cookie.serialize('auth', authToken as string, { httpOnly: true, diff --git a/packages/api/src/routers/auth/mobile/account_creation.ts b/packages/api/src/routers/auth/mobile/account_creation.ts deleted file mode 100644 index b19607e1d8..0000000000 --- a/packages/api/src/routers/auth/mobile/account_creation.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { JsonResponsePayload, UserProfile } from '../auth_types' -import { - createMobileAuthPayload, - decodePendingUserToken, -} from './../jwt_helpers' -import { createUser } from '../../../services/create_user' -import { SignupErrorCode } from '../../../generated/graphql' - -export async function createMobileAccountCreationResponse( - pendingUserToken?: string, - userProfile?: UserProfile -): Promise { - try { - if ( - typeof pendingUserToken !== 'string' || - typeof userProfile !== 'object' - ) { - return accountCreationFailedPayload - } - - const decodedToken = decodePendingUserToken(pendingUserToken) - - if (!decodedToken) { - return accountCreationFailedPayload - } - - const { email, provider, sourceUserId } = decodedToken - const [user] = await createUser({ - email, - sourceUserId, - provider, - name: userProfile.name, - username: userProfile.username, - pictureUrl: undefined, - bio: userProfile.bio || undefined, - }) - - const mobileAuthPayload = await createMobileAuthPayload(user.id) - - return { - statusCode: 200, - json: mobileAuthPayload, - } - } catch (error) { - if (isErrorWithCode(error)) { - if (error.errorCode === SignupErrorCode.UserExists) { - return { - statusCode: 400, - json: { errorCodes: ['USER_ALREADY_EXISTS'] }, - } - } - } - return accountCreationFailedPayload - } -} - -const accountCreationFailedPayload = { - statusCode: 400, - json: { errorCodes: ['AUTH_FAILED'] }, -} - -type ErrorWithCode = { - errorCode: string -} - -export function isErrorWithCode(error: unknown): error is ErrorWithCode { - return ( - (error as ErrorWithCode).errorCode !== undefined && - typeof (error as ErrorWithCode).errorCode === 'string' - ) -} diff --git a/packages/api/src/routers/auth/mobile/mobile_auth_router.ts b/packages/api/src/routers/auth/mobile/mobile_auth_router.ts index f27a032fa4..5cc35ec2bc 100644 --- a/packages/api/src/routers/auth/mobile/mobile_auth_router.ts +++ b/packages/api/src/routers/auth/mobile/mobile_auth_router.ts @@ -10,7 +10,7 @@ import { createMobileSignUpResponse, createMobileEmailSignUpResponse, } from './sign_up' -import { createMobileAccountCreationResponse } from './account_creation' +import { createAccountCreationResponse } from '../../../services/account_creation' import { env } from '../../../env' import { corsConfig } from '../../../utils/corsConfig' import cors from 'cors' @@ -51,7 +51,7 @@ export function mobileAuthRouter() { router.post('/create-account', async (req, res) => { const { pendingUserToken, userProfile } = req.body - const payload = await createMobileAccountCreationResponse( + const payload = await createAccountCreationResponse( pendingUserToken, userProfile ) diff --git a/packages/api/src/routers/auth/mobile/sign_in.ts b/packages/api/src/routers/auth/mobile/sign_in.ts index ee4c7778ad..fa0d5d0556 100644 --- a/packages/api/src/routers/auth/mobile/sign_in.ts +++ b/packages/api/src/routers/auth/mobile/sign_in.ts @@ -11,7 +11,7 @@ import { JsonResponsePayload, } from '../auth_types' import { decodeGoogleToken } from '../google_auth' -import { createMobileAuthPayload } from '../jwt_helpers' +import { createAuthPayload } from '../jwt_helpers' export async function createMobileSignInResponse( isAndroid: boolean, @@ -67,7 +67,7 @@ export async function createMobileEmailSignInResponse( } } - const mobileAuthPayload = await createMobileAuthPayload(user.id) + const mobileAuthPayload = await createAuthPayload(user.id) return { statusCode: 200, @@ -109,7 +109,7 @@ async function createAuthResponsePayload( } } - const mobileAuthPayload = await createMobileAuthPayload(userId) + const mobileAuthPayload = await createAuthPayload(userId) return { statusCode: 200, diff --git a/packages/api/src/utils/sso.ts b/packages/api/src/utils/sso.ts index 25820d7f54..47cf1704f6 100644 --- a/packages/api/src/utils/sso.ts +++ b/packages/api/src/utils/sso.ts @@ -15,9 +15,15 @@ export const createSsoToken = ( return ssoToken } -export const ssoRedirectURL = (ssoToken: string): string => { +export const ssoRedirectURL = ( + ssoToken: string, + redirect: string | undefined +): string => { const u = new URL(homePageURL()) u.pathname = 'api/client/auth' u.searchParams.append('tok', ssoToken) + if (redirect) { + u.searchParams.append('redirect', redirect) + } return u.toString() } diff --git a/packages/db/migrations/0175.do.update_new_user_article.sql b/packages/db/migrations/0175.do.update_new_user_article.sql new file mode 100755 index 0000000000..a75bd55fec --- /dev/null +++ b/packages/db/migrations/0175.do.update_new_user_article.sql @@ -0,0 +1,17 @@ +-- Type: DO +-- Name: update_new_user_article +-- Description: Update the new user article + +BEGIN; + +UPDATE omnivore.popular_read SET + original_url = 'https://omnivore.app/articles/welcome-to-omnivore', + title = 'Welcome to Omnivore', + published_at = '2024-05-01', + site_name = 'Omnivore', + slug = 'welcome-to-omnivore', + readable_content = '

Hey, welcome to Omnivore. Omnivore was built to help you get more insights and enjoyment from your daily reading. This quick guide will get you started on that journey.

The Reader

There are two main views in Omnivore: the reader and the library. Right now you are in the reader view. The reader strips distractions out of web pages and formats things nicely so you can enjoy what's important: the content.

In the reader you can select text and add highlights (try it out!). You can also create notes, and add notes to your highlights. If you are an iOS user you can listen to your articles using text to speech.

Add highlights

When you are done reading an article you can delete it or archive it using the buttons on the lefthand side of the reader (or at the top on mobile). Archived items are saved forever, so you still have the content even if the site goes away.

The Library

The other important view in Omnivore is the library. This is where all your saved items will appear. You can organize your library by creating labels and saved searches.

The Library

When you are starting out with Omnivore it is important to learn how to save items.

The easiest way is the add button at the bottom of the menu on the left side of the library. Clicking this will open the add dialog where you can save links, upload PDFs, subscribe to feeds, or import items from a legacy reader app.

Besides the Add button, you can also use the mobile apps and browser extensions to save items.

You can organize things in the library by creating labels and saved searches.

Subscriptions

Many users enjoy reading their subscriptions in Omnivore. In fact, we find the most productive users usually have a few (but not too many) subscriptions. With Omnivore you can create an email address and use it to subscribe to newsletters or you can add rss and atom feeds.

Native Apps and Browser Extensions

Omnivore has browser extensions and mobile apps to make saving and reading easier.

Our native iOS and Android apps sync your reading to the device so it’s available offline. They also make it easier to save items by providing share extensions, and on iOS allow you to listen to your articles with text-to-speech.

You can install the native iOS app here and the native Android (prelease) app here.

Browser extensions can save the URLs and the content of the page you are viewing. They also allow creating notes and adding labels while saving. We have browser extensions available for Firefox, Chrome, Edge, and Safari.

Get Started

Our goal is to help people gain more insights and enjoyment from their daily reading. If there’s something we could do to improve that, please use the feedback button from the menu.

Now, archive this article and go enjoy some reading!

', + original_content = '' + where popular_read.key = 'omnivore_get_started'; + +COMMIT; diff --git a/packages/db/migrations/0175.undo.update_new_user_article.sql b/packages/db/migrations/0175.undo.update_new_user_article.sql new file mode 100755 index 0000000000..8850a312f0 --- /dev/null +++ b/packages/db/migrations/0175.undo.update_new_user_article.sql @@ -0,0 +1,7 @@ +-- Type: UNDO +-- Name: update_new_user_article +-- Description: Update the new user article + +BEGIN; + +COMMIT; diff --git a/packages/web/components/patterns/ArticleSubtitle.tsx b/packages/web/components/patterns/ArticleSubtitle.tsx index dbda5b69c0..8c5cb35ffa 100644 --- a/packages/web/components/patterns/ArticleSubtitle.tsx +++ b/packages/web/components/patterns/ArticleSubtitle.tsx @@ -18,7 +18,10 @@ export function ArticleSubtitle(props: ArticleSubtitleProps): JSX.Element { return ( - {subtitle} {subtitle && }{' '} + {subtitle}{' '} + {subtitle && !shouldHideUrl(props.href) && ( + + )}{' '} {!props.hideButton && !shouldHideUrl(props.href) && ( <> { + }).then(async (response) => { + const body = await response.json() + console.log('response: ', body) if (response.status === 200) { window.localStorage.setItem('authVerified', 'true') - window.location.href = '/home' + window.location.href = body['redirect'] ?? '/home' } else { setErrorMessage('Error creating account') } diff --git a/packages/web/components/templates/PrimaryLayout.tsx b/packages/web/components/templates/PrimaryLayout.tsx index ce661f161e..d05103af8f 100644 --- a/packages/web/components/templates/PrimaryLayout.tsx +++ b/packages/web/components/templates/PrimaryLayout.tsx @@ -14,7 +14,6 @@ import { useApplyLocalTheme } from '../../lib/hooks/useApplyLocalTheme' import { updateTheme } from '../../lib/themeUpdater' import { Priority, useRegisterActions } from 'kbar' import { ThemeId } from '../tokens/stitches.config' -import { useVerifyAuth } from '../../lib/hooks/useVerifyAuth' type PrimaryLayoutProps = { children: ReactNode diff --git a/packages/web/components/templates/SettingsLayout.tsx b/packages/web/components/templates/SettingsLayout.tsx index 71f8f44c66..7b8a666a98 100644 --- a/packages/web/components/templates/SettingsLayout.tsx +++ b/packages/web/components/templates/SettingsLayout.tsx @@ -11,7 +11,7 @@ import { DEFAULT_HEADER_HEIGHT } from './homeFeed/HeaderSpacer' import { logout } from '../../lib/logout' import { SettingsMenu } from './navMenu/SettingsMenu' import { SettingsDropdown } from './navMenu/SettingsDropdown' -import { useVerifyAuth } from '../../lib/hooks/useVerifyAuth' +import { useIsUserLoggedOut } from '../../lib/hooks/useIsUserLoggedOut' import Link from 'next/link' import { CaretLeft } from 'phosphor-react' @@ -53,9 +53,8 @@ const ReturnButton = (): JSX.Element => { } export function SettingsLayout(props: SettingsLayoutProps): JSX.Element { - useVerifyAuth() - const router = useRouter() + const userLoggedOut = useIsUserLoggedOut() const [showLogoutConfirmation, setShowLogoutConfirmation] = useState(false) const [showKeyboardCommandsModal, setShowKeyboardCommandsModal] = useState(false) @@ -67,6 +66,12 @@ export function SettingsLayout(props: SettingsLayoutProps): JSX.Element { setShowLogoutConfirmation(true) }, [setShowLogoutConfirmation]) + useEffect(() => { + if (userLoggedOut) { + router.replace('/login') + } + }, [userLoggedOut]) + useEffect(() => { document.addEventListener('logout', showLogout) diff --git a/packages/web/lib/hooks/useIsUserLoggedOut.tsx b/packages/web/lib/hooks/useIsUserLoggedOut.tsx new file mode 100644 index 0000000000..9ec2563487 --- /dev/null +++ b/packages/web/lib/hooks/useIsUserLoggedOut.tsx @@ -0,0 +1,18 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/router' +import useSWR from 'swr' +import { apiFetcher } from '../networking/networkHelpers' +import { fetchEndpoint } from '../appConfig' + +export function useIsUserLoggedOut() { + const response = useSWR(`${fetchEndpoint}/auth/verify`, apiFetcher) + // We are not sure yet + if (!response.data && !response.error) { + return false + } + if (!response.error && 'authStatus' in (response.data as any)) { + const { authStatus } = response.data as any + return authStatus !== 'AUTHENTICATED' + } + return true +} diff --git a/packages/web/lib/hooks/useVerifyAuth.tsx b/packages/web/lib/hooks/useVerifyAuth.tsx deleted file mode 100644 index e641f0768e..0000000000 --- a/packages/web/lib/hooks/useVerifyAuth.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect } from 'react' -import { useRouter } from 'next/router' - -export function useVerifyAuth() { - const router = useRouter() - - useEffect(() => { - if (!window.localStorage.getItem('authVerified')) { - window.location.href = `/login?redirect=${window.location.pathname}` - return - } - }, [router]) -} diff --git a/packages/web/pages/api/client/auth.ts b/packages/web/pages/api/client/auth.ts index ee1d2dc015..2f7dec800c 100644 --- a/packages/web/pages/api/client/auth.ts +++ b/packages/web/pages/api/client/auth.ts @@ -18,6 +18,7 @@ const requestHandler = (req: NextApiRequest, res: NextApiResponse): void => { } const tok = req.query.tok + const redirect = req.query.redirect if (ssoJwtSecret && tok && !Array.isArray(tok)) { const payload = jwt.verify(tok, ssoJwtSecret) as AuthPayload res.setHeader( @@ -29,7 +30,7 @@ const requestHandler = (req: NextApiRequest, res: NextApiResponse): void => { }) } else { res.writeHead(302, { - Location: '/home', + Location: redirect ?? '/home', }) } diff --git a/packages/web/pages/api/client/logout.ts b/packages/web/pages/api/client/logout.ts index 58e2faadb7..16db73bebd 100644 --- a/packages/web/pages/api/client/logout.ts +++ b/packages/web/pages/api/client/logout.ts @@ -3,7 +3,7 @@ import { withSentry } from '@sentry/nextjs' import { serialize } from 'cookie' const requestHandler = (req: NextApiRequest, res: NextApiResponse): void => { - res.setHeader('Set-Cookie', serialize('auth', '', { maxAge: -1 })) + res.setHeader('Set-Cookie', serialize('auth', '', { expires: new Date(0) })) res.send('logged out') } diff --git a/packages/web/pages/settings/delete-my-account.tsx b/packages/web/pages/settings/delete-my-account.tsx index de0b35a890..2796c8a667 100644 --- a/packages/web/pages/settings/delete-my-account.tsx +++ b/packages/web/pages/settings/delete-my-account.tsx @@ -14,6 +14,7 @@ import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuer import { Loader } from '../../components/templates/SavingRequest' import { deleteAccountMutation } from '../../lib/networking/mutations/deleteAccountMutation' import Link from 'next/link' +import { webBaseURL } from '../../lib/appConfig' export default function DeleteMyAccount(): JSX.Element { const router = useRouter() @@ -31,6 +32,16 @@ export default function DeleteMyAccount(): JSX.Element { const result = await deleteAccountMutation(viewerId) if (result) { + localStorage.clear() + const logout = await fetch(`${webBaseURL}/api/client/logout`, { + method: 'GET', + credentials: 'include', + mode: 'cors', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) showSuccessToast('Account deleted') setTimeout(() => { window.location.href = '/login'