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/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/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-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 new file mode 100644 index 0000000000..c2b22d1a2e --- /dev/null +++ b/apps/www/src/app/auth/confirm/route.ts @@ -0,0 +1,88 @@ +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 } = 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') + + // Use context param for redirect whoop + const context = searchParams.get('context') + + 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() + + 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 }], + }, + }) + + return NextResponse.redirect(redirectTo) + } catch (e) { + throw Error(e) + // 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)) +} 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 new file mode 100644 index 0000000000..0a778f5a51 --- /dev/null +++ b/apps/www/src/app/auth/login/login-form.tsx @@ -0,0 +1,198 @@ +'use client' +import { + AuthorizeSessionDocument, + MeDocument, + SignInDocument, + SignInTokenType, + SignOutDocument, +} from '#graphql/republik-api/__generated__/gql/graphql' +import { useMutation, useQuery } from '@apollo/client' +import { css } from '@republik/theme/css' +import { useSearchParams } from 'next/navigation' +import { useEffect, 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 ( + + ) +} + +function useSignIn() { + 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, + loading: meQuery.loading || signInQuery.loading, + me: meQuery.data?.me, + data: signInQuery.data?.signIn, + } +} + +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() + + const [email, setEmail] = useState('') + const emailId = useId() + const searchParams = useSearchParams() + + if (error) { + return
Ups
+ } + + if (loading) { + return
Momentchen …
+ } + + // Logged in + if (me) { + return ( + <> + Eingeloggt! + + ) + } + + // Signing in + if (data) { + const { tokenType, phrase, alternativeFirstFactors } = data + + if (tokenType === SignInTokenType.EmailCode) { + return + } + + return ( + <> +

+ Check dein {tokenType}: {phrase} +

+ + + + ) + } + + // Show login form + return ( +
+
{ + e.preventDefault() + + if (typeof email === 'string') { + signIn({ + variables: { + context: searchParams.get('redirect'), + email, + tokenType: SignInTokenType.EmailCode, + }, + }) + } + }} + > + + setEmail(e.currentTarget.value)} + > + +
+
+ ) +} 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 + +
+ ) +}