From c2a891d9c34ee341e1a0c98eef809427813937d6 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Wed, 26 Jun 2024 16:38:58 +0200 Subject: [PATCH 1/8] feat: modernize sign in --- .../graphql/republik-api/mutations/SignIn.gql | 19 ++++ .../republik-api/mutations/SignOut.gql | 3 + apps/www/src/app/auth/login/login-form.tsx | 96 +++++++++++++++++++ apps/www/src/app/auth/login/page.tsx | 10 ++ 4 files changed, 128 insertions(+) create mode 100644 apps/www/graphql/republik-api/mutations/SignIn.gql create mode 100644 apps/www/graphql/republik-api/mutations/SignOut.gql create mode 100644 apps/www/src/app/auth/login/login-form.tsx create mode 100644 apps/www/src/app/auth/login/page.tsx diff --git a/apps/www/graphql/republik-api/mutations/SignIn.gql b/apps/www/graphql/republik-api/mutations/SignIn.gql new file mode 100644 index 0000000000..04c035ca4d --- /dev/null +++ b/apps/www/graphql/republik-api/mutations/SignIn.gql @@ -0,0 +1,19 @@ +mutation signIn( + $email: String! + $context: String + $consents: [String!] + $tokenType: SignInTokenType + $accessToken: ID +) { + signIn( + email: $email + context: $context + consents: $consents + tokenType: $tokenType + accessToken: $accessToken + ) { + phrase + tokenType + alternativeFirstFactors + } +} diff --git a/apps/www/graphql/republik-api/mutations/SignOut.gql b/apps/www/graphql/republik-api/mutations/SignOut.gql new file mode 100644 index 0000000000..b7e440b85e --- /dev/null +++ b/apps/www/graphql/republik-api/mutations/SignOut.gql @@ -0,0 +1,3 @@ +mutation signOut { + signOut +} diff --git a/apps/www/src/app/auth/login/login-form.tsx b/apps/www/src/app/auth/login/login-form.tsx new file mode 100644 index 0000000000..d612853e17 --- /dev/null +++ b/apps/www/src/app/auth/login/login-form.tsx @@ -0,0 +1,96 @@ +'use client' +import { + MeDocument, + SignInDocument, + SignOutDocument, +} from '#graphql/republik-api/__generated__/gql/graphql' +import { NetworkStatus, useMutation, useQuery } from '@apollo/client' +import { css } from '@republik/theme/css' +import { useId } from 'react' + +function LogOut() { + const [signOut, { loading }] = useMutation(SignOutDocument) + + return ( + + ) +} + +function WaitForLogin({ phrase }: { phrase: string }) { + const { data, loading, error, networkStatus } = useQuery(MeDocument, { + pollInterval: 1000, + notifyOnNetworkStatusChange: true, + }) + + if (data?.me) { + return ( + <> + Logged in! + + ) + } + + if (error) { + console.error(error) + } + + return ( + <> +

+ Check dein Mail: {phrase} +

+
data: {JSON.stringify(data, null, 2)}
+
loading: {loading ? 'true' : 'false'}
+
poll: {networkStatus === NetworkStatus.poll ? 'true' : 'false'}
+
error: {error?.message}
+ + ) +} + +export function LoginForm() { + const [signIn, { data, loading, error }] = useMutation(SignInDocument, {}) + const emailId = useId() + + if (data?.signIn) { + return + } + + if (error) { + return
Ups
+ } + + return ( +
+
{ + e.preventDefault() + const email = (document.getElementById(emailId) as HTMLInputElement) + ?.value + if (typeof email === 'string') { + signIn({ + variables: { + email, + }, + }) + } + }} + > + + + +
+
+ ) +} diff --git a/apps/www/src/app/auth/login/page.tsx b/apps/www/src/app/auth/login/page.tsx new file mode 100644 index 0000000000..2edb8a76cf --- /dev/null +++ b/apps/www/src/app/auth/login/page.tsx @@ -0,0 +1,10 @@ +import { LoginForm } from '@app/app/auth/login/login-form' + +export default function Page() { + return ( +
+ hoi + +
+ ) +} From c016ff1891511343d24bf105e3350ae3baabd8f0 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Fri, 28 Jun 2024 14:26:43 +0200 Subject: [PATCH 2/8] feat: auth confirm api route --- .../mutations/AuthorizeSession.gql | 13 ++++ .../mutations/UnauthorizedSession.gql | 29 ++++++++ apps/www/src/app/auth/confirm/route.ts | 67 +++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 apps/www/graphql/republik-api/mutations/AuthorizeSession.gql create mode 100644 apps/www/graphql/republik-api/mutations/UnauthorizedSession.gql create mode 100644 apps/www/src/app/auth/confirm/route.ts diff --git a/apps/www/graphql/republik-api/mutations/AuthorizeSession.gql b/apps/www/graphql/republik-api/mutations/AuthorizeSession.gql new file mode 100644 index 0000000000..4c51ea9e1a --- /dev/null +++ b/apps/www/graphql/republik-api/mutations/AuthorizeSession.gql @@ -0,0 +1,13 @@ +mutation authorizeSession( + $email: String! + $tokens: [SignInToken!]! + $consents: [String!] + $requiredFields: RequiredUserFields +) { + authorizeSession( + email: $email + tokens: $tokens + consents: $consents + requiredFields: $requiredFields + ) +} diff --git a/apps/www/graphql/republik-api/mutations/UnauthorizedSession.gql b/apps/www/graphql/republik-api/mutations/UnauthorizedSession.gql new file mode 100644 index 0000000000..ab84ffe5d9 --- /dev/null +++ b/apps/www/graphql/republik-api/mutations/UnauthorizedSession.gql @@ -0,0 +1,29 @@ +query unauthorizedSession( + $email: String! + $token: String! + $tokenType: SignInTokenType! +) { + echo { + ipAddress + userAgent + country + city + } + unauthorizedSession( + email: $email + token: { type: $tokenType, payload: $token } + ) { + newUser + enabledSecondFactors + requiredConsents + requiredFields + session { + ipAddress + userAgent + country + city + phrase + isCurrent + } + } +} diff --git a/apps/www/src/app/auth/confirm/route.ts b/apps/www/src/app/auth/confirm/route.ts new file mode 100644 index 0000000000..838cbf9769 --- /dev/null +++ b/apps/www/src/app/auth/confirm/route.ts @@ -0,0 +1,67 @@ +import { + AuthorizeSessionDocument, + SignInTokenType, + UnauthorizedSessionDocument, +} from '#graphql/republik-api/__generated__/gql/graphql' +import { getClient } from '@app/lib/apollo/client' +import { NextResponse, type NextRequest } from 'next/server' + +// TODO: add more types +type EmailOtpType = 'token-authorization' + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + // const token_hash = searchParams.get('token_hash') + const type = searchParams.get('type') as EmailOtpType | null + const email = Buffer.from(searchParams.get('email') ?? '', 'base64').toString( + 'utf8', + ) + const token = searchParams.get('token') + // const next = searchParams.get('next') ?? '/' + + const redirectTo = request.nextUrl.clone() + + // TODO VALIDATE THINGS + + const gqlClient = getClient() + + try { + const { data } = await gqlClient.query({ + query: UnauthorizedSessionDocument, + variables: { + email, + token, + tokenType: SignInTokenType.EmailToken, + }, + }) + + // If not the current session, show confirm dialog + if (!data.unauthorizedSession.session.isCurrent) { + // TODO: make a new dialog + redirectTo.pathname = '/mitteilung' + return NextResponse.redirect(redirectTo) + } + + // If current session, just authorize and redirect to target + await gqlClient.mutate({ + mutation: AuthorizeSessionDocument, + variables: { + email, + tokens: [{ type: SignInTokenType.EmailToken, payload: token }], + }, + }) + + // TODO add actual redirect + redirectTo.pathname = '/' + + redirectTo.searchParams.delete('email') + redirectTo.searchParams.delete('token') + redirectTo.searchParams.delete('tokenType') + redirectTo.searchParams.delete('context') + redirectTo.searchParams.delete('type') + return NextResponse.redirect(redirectTo) + } catch (e) { + throw Error(e) + // return NextResponse.redirect(redirectTo) + } +} From 0e696967682492b3d36aaf21924b5d59d0f1d741 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Fri, 28 Jun 2024 16:11:27 +0200 Subject: [PATCH 3/8] feat: confirm and redirect --- apps/www/src/app/auth/confirm/route.ts | 22 ++++++++++------------ apps/www/src/app/auth/login/login-form.tsx | 4 +++- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/www/src/app/auth/confirm/route.ts b/apps/www/src/app/auth/confirm/route.ts index 838cbf9769..9965c5b20f 100644 --- a/apps/www/src/app/auth/confirm/route.ts +++ b/apps/www/src/app/auth/confirm/route.ts @@ -10,18 +10,24 @@ import { NextResponse, type NextRequest } from 'next/server' type EmailOtpType = 'token-authorization' export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url) + const { searchParams } = request.nextUrl // const token_hash = searchParams.get('token_hash') const type = searchParams.get('type') as EmailOtpType | null const email = Buffer.from(searchParams.get('email') ?? '', 'base64').toString( 'utf8', ) const token = searchParams.get('token') - // const next = searchParams.get('next') ?? '/' - const redirectTo = request.nextUrl.clone() + // Use context param for redirect whoop + const context = searchParams.get('context') - // TODO VALIDATE THINGS + let redirectTo: URL + try { + // TODO: validate context as URL/pathname + redirectTo = new URL(context ?? '/', request.nextUrl) + } catch (e) { + redirectTo = new URL('/', request.nextUrl) + } const gqlClient = getClient() @@ -51,14 +57,6 @@ export async function GET(request: NextRequest) { }, }) - // TODO add actual redirect - redirectTo.pathname = '/' - - redirectTo.searchParams.delete('email') - redirectTo.searchParams.delete('token') - redirectTo.searchParams.delete('tokenType') - redirectTo.searchParams.delete('context') - redirectTo.searchParams.delete('type') return NextResponse.redirect(redirectTo) } catch (e) { throw Error(e) diff --git a/apps/www/src/app/auth/login/login-form.tsx b/apps/www/src/app/auth/login/login-form.tsx index d612853e17..044641b7ba 100644 --- a/apps/www/src/app/auth/login/login-form.tsx +++ b/apps/www/src/app/auth/login/login-form.tsx @@ -6,6 +6,7 @@ import { } from '#graphql/republik-api/__generated__/gql/graphql' import { NetworkStatus, useMutation, useQuery } from '@apollo/client' import { css } from '@republik/theme/css' +import { useSearchParams } from 'next/navigation' import { useId } from 'react' function LogOut() { @@ -55,7 +56,7 @@ function WaitForLogin({ phrase }: { phrase: string }) { export function LoginForm() { const [signIn, { data, loading, error }] = useMutation(SignInDocument, {}) const emailId = useId() - + const searchParams = useSearchParams() if (data?.signIn) { return } @@ -79,6 +80,7 @@ export function LoginForm() { if (typeof email === 'string') { signIn({ variables: { + context: searchParams.get('redirect'), email, }, }) From c58ab06be03612491a868a249660d3a49ed40232 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Fri, 28 Jun 2024 17:35:30 +0200 Subject: [PATCH 4/8] feat: wip confirm dialog --- apps/www/src/app/auth/confirm-dialog/page.tsx | 14 +++++++++++ apps/www/src/app/auth/confirm/route.ts | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 apps/www/src/app/auth/confirm-dialog/page.tsx diff --git a/apps/www/src/app/auth/confirm-dialog/page.tsx b/apps/www/src/app/auth/confirm-dialog/page.tsx new file mode 100644 index 0000000000..da57f66378 --- /dev/null +++ b/apps/www/src/app/auth/confirm-dialog/page.tsx @@ -0,0 +1,14 @@ +export default async function ConfirmDialog({ searchParams }) { + return ( +
+
{JSON.stringify(searchParams, null, 2)}
+ + {Object.entries(searchParams).map(([k, v]) => { + return + })} + + + +
+ ) +} diff --git a/apps/www/src/app/auth/confirm/route.ts b/apps/www/src/app/auth/confirm/route.ts index 9965c5b20f..c2b22d1a2e 100644 --- a/apps/www/src/app/auth/confirm/route.ts +++ b/apps/www/src/app/auth/confirm/route.ts @@ -63,3 +63,26 @@ export async function GET(request: NextRequest) { // return NextResponse.redirect(redirectTo) } } + +export async function POST(request: NextRequest) { + const fd = await request.formData() + + const email = Buffer.from( + (fd.get('email') as string) ?? '', + 'base64', + ).toString('utf8') + + const token = fd.get('token') as string + + const gqlClient = getClient() + + await gqlClient.mutate({ + mutation: AuthorizeSessionDocument, + variables: { + email, + tokens: [{ type: SignInTokenType.EmailToken, payload: token }], + }, + }) + + return NextResponse.redirect(new URL('/auth/confirm-ok', request.nextUrl)) +} From 576530cedf3eb35b0d937284d6ff55ed6dcaf9f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Wed, 3 Jul 2024 14:10:19 +0200 Subject: [PATCH 5/8] refactor: streamline login form --- apps/www/src/app/auth/login/login-form.tsx | 107 ++++++++++++++------- 1 file changed, 73 insertions(+), 34 deletions(-) diff --git a/apps/www/src/app/auth/login/login-form.tsx b/apps/www/src/app/auth/login/login-form.tsx index 044641b7ba..1eafd00a48 100644 --- a/apps/www/src/app/auth/login/login-form.tsx +++ b/apps/www/src/app/auth/login/login-form.tsx @@ -2,12 +2,13 @@ import { MeDocument, SignInDocument, + SignInTokenType, SignOutDocument, } from '#graphql/republik-api/__generated__/gql/graphql' -import { NetworkStatus, useMutation, useQuery } from '@apollo/client' +import { useMutation, useQuery } from '@apollo/client' import { css } from '@republik/theme/css' import { useSearchParams } from 'next/navigation' -import { useId } from 'react' +import { useId, useState } from 'react' function LogOut() { const [signOut, { loading }] = useMutation(SignOutDocument) @@ -22,49 +23,81 @@ function LogOut() { ) } -function WaitForLogin({ phrase }: { phrase: string }) { - const { data, loading, error, networkStatus } = useQuery(MeDocument, { - pollInterval: 1000, - notifyOnNetworkStatusChange: true, +function useSignIn() { + const meQuery = useQuery(MeDocument, { + pollInterval: 2000, }) - if (data?.me) { - return ( - <> - Logged in! - - ) - } + const [signIn, signInQuery] = useMutation(SignInDocument, {}) - if (error) { - console.error(error) + return { + signIn, + error: meQuery.error || signInQuery.error, + loading: meQuery.loading || signInQuery.loading, + me: meQuery.data?.me, + data: signInQuery.data?.signIn, } - - return ( - <> -

- Check dein Mail: {phrase} -

-
data: {JSON.stringify(data, null, 2)}
-
loading: {loading ? 'true' : 'false'}
-
poll: {networkStatus === NetworkStatus.poll ? 'true' : 'false'}
-
error: {error?.message}
- - ) } export function LoginForm() { - const [signIn, { data, loading, error }] = useMutation(SignInDocument, {}) + const { signIn, me, error, loading, data } = useSignIn() + + const [email, setEmail] = useState('') const emailId = useId() const searchParams = useSearchParams() - if (data?.signIn) { - return - } if (error) { return
Ups
} + if (loading) { + return
Momentchen …
+ } + + // Logged in + if (me) { + return ( + <> + Eingeloggt! + + ) + } + + // Signing in + if (data) { + const { tokenType, phrase, alternativeFirstFactors } = data + return ( + <> +

+ Check dein {tokenType}: {phrase} +

+ +
    + {alternativeFirstFactors.map((altTokenType) => { + return ( +
    { + signIn({ + variables: { + email, + tokenType: altTokenType, + }, + }) + }} + > + +
    + ) + })} +
+ + ) + } + + // Show login form return (
{ e.preventDefault() - const email = (document.getElementById(emailId) as HTMLInputElement) - ?.value + if (typeof email === 'string') { signIn({ variables: { context: searchParams.get('redirect'), email, + // tokenType: SignInTokenType.EmailCode, }, }) } }} > - + setEmail(e.currentTarget.value)} + > From c6d17eee4e9781fd2141db74c31bcdbaa3f275b4 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Wed, 3 Jul 2024 14:22:53 +0200 Subject: [PATCH 6/8] feat: add code auth component --- apps/www/src/app/auth/login/login-form.tsx | 34 +++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/www/src/app/auth/login/login-form.tsx b/apps/www/src/app/auth/login/login-form.tsx index 1eafd00a48..bfa710972c 100644 --- a/apps/www/src/app/auth/login/login-form.tsx +++ b/apps/www/src/app/auth/login/login-form.tsx @@ -1,5 +1,6 @@ 'use client' import { + AuthorizeSessionDocument, MeDocument, SignInDocument, SignInTokenType, @@ -39,6 +40,32 @@ function useSignIn() { } } +function AuthorizeCode({ email }: { email: string }) { + const [authorizeSession] = useMutation(AuthorizeSessionDocument) + return ( + { + e.preventDefault() + const formData = new FormData(e.currentTarget) + + const code = (formData.get('code') as string) + ?.replace(/[^0-9]/g, '') + .slice(0, 6) + + authorizeSession({ + variables: { + email, + tokens: [{ type: SignInTokenType.EmailCode, payload: code }], + }, + }) + }} + > + + + + ) +} + export function LoginForm() { const { signIn, me, error, loading, data } = useSignIn() @@ -66,6 +93,11 @@ export function LoginForm() { // Signing in if (data) { const { tokenType, phrase, alternativeFirstFactors } = data + + if (tokenType === SignInTokenType.EmailCode) { + return + } + return ( <>

@@ -114,7 +146,7 @@ export function LoginForm() { variables: { context: searchParams.get('redirect'), email, - // tokenType: SignInTokenType.EmailCode, + tokenType: SignInTokenType.EmailCode, }, }) } From 611f6c0a12746a518f328c48a8b7903e998a6ac4 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Wed, 3 Jul 2024 14:37:43 +0200 Subject: [PATCH 7/8] feat: a little bit of styling --- apps/www/src/app/auth/layout.tsx | 31 ++++++++++++++++++++++ apps/www/src/app/auth/login/login-form.tsx | 28 ++++++++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 apps/www/src/app/auth/layout.tsx diff --git a/apps/www/src/app/auth/layout.tsx b/apps/www/src/app/auth/layout.tsx new file mode 100644 index 0000000000..8e37c3862b --- /dev/null +++ b/apps/www/src/app/auth/layout.tsx @@ -0,0 +1,31 @@ +import Container from '@app/components/container' +import { PageLayout } from '@app/components/layout' +import { css } from '@republik/theme/css' +import { Metadata } from 'next' + +export const metadata: Metadata = { + title: { + default: 'Anmelden', + template: '%s – Anmelden – Republik', + }, +} + +export const revalidate = 60 // revalidate all event pages every minute + +export default async function Layout(props: { children: React.ReactNode }) { + return ( +

+ +
+ {props.children} +
+
+
+ ) +} diff --git a/apps/www/src/app/auth/login/login-form.tsx b/apps/www/src/app/auth/login/login-form.tsx index bfa710972c..e81c599200 100644 --- a/apps/www/src/app/auth/login/login-form.tsx +++ b/apps/www/src/app/auth/login/login-form.tsx @@ -11,11 +11,24 @@ import { css } from '@republik/theme/css' import { useSearchParams } from 'next/navigation' import { useId, useState } from 'react' +const buttonStyle = css({ + background: 'primary', + color: 'white', + px: '4', + py: '3', +}) + +const inputStyle = css({ + borderBottom: '1px solid token(colors.text)', + py: '3', +}) + function LogOut() { const [signOut, { loading }] = useMutation(SignOutDocument) return ( + + ) } @@ -118,7 +133,11 @@ export function LoginForm() { }) }} > - @@ -154,13 +173,14 @@ export function LoginForm() { > setEmail(e.currentTarget.value)} > - From 12d9839963ea1e0b537966f75c26159b88457f33 Mon Sep 17 00:00:00 2001 From: Jeremy Stucki Date: Thu, 4 Jul 2024 11:20:49 +0200 Subject: [PATCH 8/8] fix: only poll during signin --- apps/www/src/app/auth/login/login-form.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/www/src/app/auth/login/login-form.tsx b/apps/www/src/app/auth/login/login-form.tsx index e81c599200..0a778f5a51 100644 --- a/apps/www/src/app/auth/login/login-form.tsx +++ b/apps/www/src/app/auth/login/login-form.tsx @@ -9,7 +9,7 @@ import { import { useMutation, useQuery } from '@apollo/client' import { css } from '@republik/theme/css' import { useSearchParams } from 'next/navigation' -import { useId, useState } from 'react' +import { useEffect, useId, useState } from 'react' const buttonStyle = css({ background: 'primary', @@ -38,12 +38,21 @@ function LogOut() { } function useSignIn() { - const meQuery = useQuery(MeDocument, { - pollInterval: 2000, - }) - + const meQuery = useQuery(MeDocument, {}) const [signIn, signInQuery] = useMutation(SignInDocument, {}) + // Only poll for `me` when a signIn request is in progress + const shouldPoll = !meQuery.data?.me && !!signInQuery.data?.signIn + + useEffect(() => { + if (shouldPoll) { + meQuery.startPolling(2000) + } else { + meQuery.stopPolling() + } + return () => meQuery.stopPolling() + }, [meQuery, shouldPoll]) + return { signIn, error: meQuery.error || signInQuery.error,