From ddd6986bcf955e973eb2dcb2087a0da5fd8ae504 Mon Sep 17 00:00:00 2001 From: LufyCZ Date: Tue, 28 Jan 2025 11:43:47 +0000 Subject: [PATCH 1/7] savepoint --- apps/web/.env.example | 6 + apps/web/next.config.mjs | 5 + apps/web/package.json | 3 + .../dashboard/[teamId]/(dashboard)/page.tsx | 3 + .../dashboard/[teamId]/_common/lib/team.ts | 10 ++ .../[teamId]/_common/ui/dashboard-sidebar.tsx | 65 +++++++ .../[teamId]/_common/ui/team-switcher.tsx | 99 +++++++++++ .../dashboard/[teamId]/layout.tsx | 15 ++ .../identity-providers-card.tsx | 26 +++ .../_common/ui/password/password-action.ts | 28 +++ .../_common/ui/password/password-card.tsx | 151 ++++++++++++++++ .../ui/password/password-form-schema.ts | 23 +++ .../(account)/_common/ui/user/user-card.tsx | 49 ++++++ .../[teamId]/settings/(account)/page.tsx | 17 ++ .../[teamId]/settings/billing/page.tsx | 3 + .../dashboard/[teamId]/settings/layout.tsx | 49 ++++++ .../dashboard/[teamId]/settings/team/page.tsx | 3 + .../dashboard/[teamId]/statistics/page.tsx | 3 + .../portal/(authenticated)/dashboard/route.ts | 13 ++ .../src/app/portal/(authenticated)/layout.tsx | 14 ++ .../resend-code/resend-code-action.ts | 31 ++++ .../resend-code/resend-code-button.tsx | 32 ++++ .../ui/verify-form/verify-email-action.ts | 57 ++++++ .../ui/verify-form/verify-form-schema.ts | 9 + .../_common/ui/verify-form/verify-form.tsx | 101 +++++++++++ .../verify/api/set-email-verified/route.ts | 30 ++++ .../portal/(authenticated)/verify/layout.tsx | 38 ++++ .../portal/(authenticated)/verify/page.tsx | 10 ++ .../_common/lib/create-zitadel-session.ts | 33 ++++ .../_common/lib/get-new-idp-intent.ts | 40 +++++ .../(unauthenticated)/_common/lib/types.ts | 13 ++ .../_common/lib/zitadel-client.ts | 40 +++++ .../_common/ui/github-button.tsx | 33 ++++ .../_common/ui/google-button.tsx | 38 ++++ .../api/auth/callback/lib/get-idp-intent.ts | 122 +++++++++++++ .../api/auth/callback/lib/get-user-by-id.ts | 48 +++++ .../api/auth/callback/lib/login.ts | 45 +++++ .../api/auth/callback/lib/register.ts | 45 +++++ .../api/auth/callback/route.ts | 89 ++++++++++ .../app/portal/(unauthenticated)/layout.tsx | 14 ++ .../login/_common/ui/login-action.ts | 131 ++++++++++++++ .../login/_common/ui/login-form-schema.ts | 7 + .../login/_common/ui/login-form.tsx | 125 +++++++++++++ .../portal/(unauthenticated)/login/layout.tsx | 9 + .../portal/(unauthenticated)/login/page.tsx | 33 ++++ .../register/_common/ui/register-action.ts | 135 ++++++++++++++ .../_common/ui/register-form-schema.ts | 24 +++ .../register/_common/ui/register-form.tsx | 164 ++++++++++++++++++ .../(unauthenticated)/register/layout.tsx | 9 + .../(unauthenticated)/register/page.tsx | 20 +++ .../src/app/portal/_common/lib/auth-env.ts | 11 ++ .../app/portal/_common/lib/client-config.ts | 67 +++++++ .../app/portal/_common/lib/logout-action.ts | 33 ++++ .../ui/auth-provider/auth-provider.tsx | 33 ++++ .../_common/ui/header/header-profile.tsx | 48 +++++ .../app/portal/_common/ui/header/header.tsx | 54 ++++++ apps/web/src/app/portal/layout.tsx | 16 ++ apps/web/src/app/portal/middleware.ts | 22 +++ apps/web/src/app/portal/page.tsx | 3 + apps/web/src/app/portal/pricing/layout.tsx | 3 + apps/web/src/app/portal/pricing/page.tsx | 3 + apps/web/src/app/portal/providers.tsx | 18 ++ apps/web/src/middleware.ts | 6 + packages/ui/src/components/form.tsx | 37 +++- packages/ui/src/components/label.tsx | 2 +- packages/ui/src/components/text-field.tsx | 5 + packages/ui/src/icons/GoogleIcon.tsx | 31 ++++ pnpm-lock.yaml | 122 +++++++++++-- 68 files changed, 2596 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/(dashboard)/page.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/lib/team.ts create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/ui/dashboard-sidebar.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/ui/team-switcher.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/layout.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/identity-providers/identity-providers-card.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-action.ts create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-card.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-form-schema.ts create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/user/user-card.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/page.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/billing/page.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/layout.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/team/page.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/statistics/page.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/dashboard/route.ts create mode 100644 apps/web/src/app/portal/(authenticated)/layout.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/resend-code/resend-code-action.ts create mode 100644 apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/resend-code/resend-code-button.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-email-action.ts create mode 100644 apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-form-schema.ts create mode 100644 apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-form.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/verify/api/set-email-verified/route.ts create mode 100644 apps/web/src/app/portal/(authenticated)/verify/layout.tsx create mode 100644 apps/web/src/app/portal/(authenticated)/verify/page.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/_common/lib/create-zitadel-session.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/_common/lib/get-new-idp-intent.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/_common/lib/types.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/_common/lib/zitadel-client.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/_common/ui/github-button.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/_common/ui/google-button.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/get-idp-intent.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/get-user-by-id.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/login.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/register.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/api/auth/callback/route.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/layout.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-action.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-form-schema.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-form.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/login/layout.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/login/page.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-action.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-form-schema.ts create mode 100644 apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-form.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/register/layout.tsx create mode 100644 apps/web/src/app/portal/(unauthenticated)/register/page.tsx create mode 100644 apps/web/src/app/portal/_common/lib/auth-env.ts create mode 100644 apps/web/src/app/portal/_common/lib/client-config.ts create mode 100644 apps/web/src/app/portal/_common/lib/logout-action.ts create mode 100644 apps/web/src/app/portal/_common/ui/auth-provider/auth-provider.tsx create mode 100644 apps/web/src/app/portal/_common/ui/header/header-profile.tsx create mode 100644 apps/web/src/app/portal/_common/ui/header/header.tsx create mode 100644 apps/web/src/app/portal/layout.tsx create mode 100644 apps/web/src/app/portal/middleware.ts create mode 100644 apps/web/src/app/portal/page.tsx create mode 100644 apps/web/src/app/portal/pricing/layout.tsx create mode 100644 apps/web/src/app/portal/pricing/page.tsx create mode 100644 apps/web/src/app/portal/providers.tsx create mode 100644 packages/ui/src/icons/GoogleIcon.tsx diff --git a/apps/web/.env.example b/apps/web/.env.example index 93baa7328b..2b348dbc32 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -21,3 +21,9 @@ NODE_OPTIONS=--inspect NEXT_PUBLIC_TRON_PRO_API_KEY='' BITQUERY_API_KEY='' BITQUERY_BEARER_TOKEN='' + +AUTH_SESSION_SECRET="" +ZITADEL_ISSUER="" +ZITADEL_CLIENT_ID="" +ZITADEL_CLIENT_SECRET="" +ZITADEL_SA_TOKEN="" \ No newline at end of file diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 5933fb4b18..4ce5cf958c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -8,6 +8,11 @@ const bundleAnalyzer = withBundleAnalyzer({ /** @type {import('next').NextConfig} */ const nextConfig = bundleAnalyzer({ ...defaultNextConfig, + logging: { + fetches: { + fullUrl: true, + }, + }, experimental: { ...defaultNextConfig.experimental, testProxy: process.env.NEXT_PUBLIC_APP_ENV === 'test', diff --git a/apps/web/package.json b/apps/web/package.json index e3f0789895..86ad9784a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -60,6 +60,8 @@ "@vercel/speed-insights": "1.0.12", "@wagmi/connectors": "5.7.3", "@wagmi/core": "catalog:web3", + "@zitadel/client": "1.0.2", + "@zitadel/proto": "1.0.2", "cors": "2.8.5", "d3": "7.8.4", "date-fns": "2.30.0", @@ -67,6 +69,7 @@ "echarts-for-react": "3.0.2", "fewcha-plugin-wallet-adapter": "0.1.3", "framer-motion": "11.15.0", + "iron-session": "8.0.4", "lodash.frompairs": "4.0.1", "lodash.maxby": "4.6.0", "lodash.once": "4.1.1", diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/(dashboard)/page.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/(dashboard)/page.tsx new file mode 100644 index 0000000000..1a4078939e --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/(dashboard)/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
profile
+} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/lib/team.ts b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/lib/team.ts new file mode 100644 index 0000000000..b2e9fd6f01 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/lib/team.ts @@ -0,0 +1,10 @@ +import { cache } from 'react' + +export type Team = Awaited> + +export const getTeam = cache(async (id: string) => { + return { + id, + name: `Team${id}`, + } +}) diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/ui/dashboard-sidebar.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/ui/dashboard-sidebar.tsx new file mode 100644 index 0000000000..6124952617 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/ui/dashboard-sidebar.tsx @@ -0,0 +1,65 @@ +'use client' + +import { ChartBarIcon, CogIcon, HomeIcon } from '@heroicons/react/24/solid' +import { classNames } from '@sushiswap/ui' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { TeamSwitcher } from './team-switcher' + +interface DashboardEntry { + children: React.ReactNode + selected?: boolean + href: string +} + +function DashboardEntry({ children, selected = false, href }: DashboardEntry) { + return ( + +
+ {children} +
+ + ) +} + +export function DashboardSidebar({ teamId }: { teamId: string }) { + const pathname = usePathname() + + console.log(pathname) + + return ( +
+ +
+ + + Dashboard + + + + Statistics + + + + Settings + +
+
+ ) +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/ui/team-switcher.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/ui/team-switcher.tsx new file mode 100644 index 0000000000..c15303d813 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/_common/ui/team-switcher.tsx @@ -0,0 +1,99 @@ +'use client' + +import { CheckIcon } from '@heroicons/react-v1/solid' +import { ChevronDownIcon, PlusIcon } from '@heroicons/react/24/solid' +import { useOnClickOutside } from '@sushiswap/hooks' +import { Separator, classNames } from '@sushiswap/ui' +import { useRouter } from 'next/navigation' +import { useRef, useState } from 'react' + +interface TeamSwitcher { + currentTeamId: string +} + +const teams = ['1', '2', '3', '4', '5'].map((id) => ({ + id, + name: `Team${id}`, +})) + +export function TeamSwitcher({ currentTeamId }: TeamSwitcher) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + const router = useRouter() + + useOnClickOutside(ref, () => { + setOpen(false) + }) + + const onTeamClick = (teamId: string) => { + if (currentTeamId === teamId) return + router.push(`/portal/dashboard/${teamId}`) + } + + return ( +
+
+
+
setOpen(!open)} + onKeyUp={(e) => e.key === 'Enter' && setOpen(!open)} + > + {currentTeamId} + +
+
+ {teams.map((team) => ( +
onTeamClick(team.id)} + onKeyUp={() => onTeamClick(team.id)} + > + {team.name} + {team.id === currentTeamId && ( + + )} +
+ ))} + +
+ Create team + +
+
+
+
+
+ ) +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/layout.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/layout.tsx new file mode 100644 index 0000000000..569dd38cf6 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/layout.tsx @@ -0,0 +1,15 @@ +import { DashboardSidebar } from './_common/ui/dashboard-sidebar' + +export default async function Layout({ + children, + params, +}: { children: React.ReactNode; params: Promise<{ teamId: string }> }) { + const teamId = (await params).teamId + + return ( +
+ +
{children}
+
+ ) +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/identity-providers/identity-providers-card.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/identity-providers/identity-providers-card.tsx new file mode 100644 index 0000000000..75d3a24bf4 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/identity-providers/identity-providers-card.tsx @@ -0,0 +1,26 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@sushiswap/ui' +import { GithubButton } from 'src/app/portal/(unauthenticated)/_common/ui/github-button' +import { GoogleButton } from 'src/app/portal/(unauthenticated)/_common/ui/google-button' + +export function IdentityProvidersCard() { + return ( + + + Identity Providers + Connect supported identity providers + + +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-action.ts b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-action.ts new file mode 100644 index 0000000000..9f8c0841a6 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-action.ts @@ -0,0 +1,28 @@ +'use server' + +import { changeOrCreatePasswordSchema } from './password-form-schema' + +export type FormState = + | { + error: string + } + | { + error: string + field: keyof (typeof changeOrCreatePasswordSchema)['_output'] + } + | { + success: true + } + +export async function changeOrCreatePasswordAction( + data: FormData, +): Promise { + const formData = Object.fromEntries(data.entries()) + const result = changeOrCreatePasswordSchema.safeParse(formData) + + if (!result.success) { + return { error: 'Invalid form data' } + } + + return { success: true } +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-card.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-card.tsx new file mode 100644 index 0000000000..4b9758c8e5 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-card.tsx @@ -0,0 +1,151 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + FormControl, + FormField, + FormItem, + FormLabel, + TextField, + formClassnames, + useForm, +} from '@sushiswap/ui' +import { useCallback, useEffect, useState } from 'react' +import { FormProvider } from 'react-hook-form' +import { z } from 'zod' +import { changeOrCreatePasswordAction } from './password-action' +import { changeOrCreatePasswordSchema } from './password-form-schema' + +type ChangeOrCreatePasswordValues = z.infer + +export function PasswordCard() { + const [globalErrorMsg, setGlobalErrorMsg] = useState(null) + + const form = useForm({ + defaultValues: { + password: '', + passwordConfirmation: '', + }, + mode: 'onBlur', + resolver: zodResolver(changeOrCreatePasswordSchema), + }) + + useEffect(() => { + if (form.getValues('password')) { + form.trigger(['password']) + } + if (form.getValues('passwordConfirmation')) { + form.trigger(['passwordConfirmation']) + } + }, [ + form.getValues, + form.trigger, + ...form.watch(['password', 'passwordConfirmation']), + ]) + + const onSubmit = useCallback( + async (values: ChangeOrCreatePasswordValues) => { + const formData = new FormData() + formData.append('password', values.password) + formData.append('passwordConfirmation', values.passwordConfirmation) + + const result = await changeOrCreatePasswordAction(formData) + + if ('error' in result) { + if ('field' in result) { + form.setError(result.field, { message: result.error }) + } else { + setGlobalErrorMsg(result.error) + } + } + }, + [form.setError], + ) + + const isPending = form.formState.isSubmitting + + return ( + + + Password + Change or create your password + + +
+ +
+
+ ( + + + <> + Password + onChange(e.target.value)} + onBlur={onBlur} + className={formClassnames({ isError })} + disabled={isPending} + /> + + + + )} + /> + ( + + + <> + Repeat Password + onChange(e.target.value)} + onBlur={onBlur} + className={formClassnames({ isError })} + disabled={isPending} + /> + + + + )} + /> +
+ + {globalErrorMsg && ( +
+ {globalErrorMsg} +
+ )} +
+
+
+
+
+ ) +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-form-schema.ts b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-form-schema.ts new file mode 100644 index 0000000000..264bba42b2 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/password/password-form-schema.ts @@ -0,0 +1,23 @@ +import { zPassword } from 'src/app/portal/(unauthenticated)/_common/lib/types' +import { z } from 'zod' + +export const changeOrCreatePasswordSchema = z + .object({ + password: zPassword, + passwordConfirmation: zPassword, + }) + .superRefine((data, ctx) => { + if ( + data.password && + data.passwordConfirmation && + data.password !== data.passwordConfirmation + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Passwords do not match', + path: ['passwordConfirmation'], + }) + } + + return data + }) diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/user/user-card.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/user/user-card.tsx new file mode 100644 index 0000000000..95e3a43c53 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/_common/ui/user/user-card.tsx @@ -0,0 +1,49 @@ +'use client' + +import { ClipboardDocumentIcon } from '@heroicons/react/24/solid' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + ClipboardController, + List, +} from '@sushiswap/ui' +import { useSession } from 'src/app/portal/_common/ui/auth-provider/auth-provider' + +export function UserCard() { + const session = useSession() + + if (!session.isLoggedIn) { + return null + } + + return ( + + + User Details + Useful when requesting support + + + + + + {session.user.id} + + {({ setCopied }) => ( + setCopied(session.user.id)} + /> + )} + + + + + + + ) +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/page.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/page.tsx new file mode 100644 index 0000000000..59a9da999a --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/(account)/page.tsx @@ -0,0 +1,17 @@ +import { IdentityProvidersCard } from './_common/ui/identity-providers/identity-providers-card' +import { PasswordCard } from './_common/ui/password/password-card' +import { UserCard } from './_common/ui/user/user-card' + +export default function Page() { + return ( +
+
+ + +
+
+ +
+
+ ) +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/billing/page.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/billing/page.tsx new file mode 100644 index 0000000000..d6259dfd69 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/billing/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
BILLING
+} \ No newline at end of file diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/layout.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/layout.tsx new file mode 100644 index 0000000000..484a2fb839 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/layout.tsx @@ -0,0 +1,49 @@ +'use client' + +import { Tabs, TabsList, TabsTrigger } from '@sushiswap/ui' +import { usePathname, useRouter } from 'next/navigation' + +function getTab(pathname: string) { + if (pathname.endsWith('/settings')) { + return 'account' + } + + if (pathname.endsWith('/settings/team')) { + return 'team' + } + + if (pathname.endsWith('/settings/billing')) { + return 'billing' + } + + throw new Error('Invalid pathname') +} + +export default function Layout({ + children, + params, +}: { children: React.ReactNode; params: Promise<{ teamId: string }> }) { + const pathname = usePathname() + const router = useRouter() + + return ( +
+ { + const value = _value === 'account' ? '' : `/${_value}` + + const { teamId } = await params + router.push(`/portal/dashboard/${teamId}/settings${value}`) + }} + > + + Account + Team + Billing + + + {children} +
+ ) +} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/team/page.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/team/page.tsx new file mode 100644 index 0000000000..624132a084 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/settings/team/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
TEAM
+} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/statistics/page.tsx b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/statistics/page.tsx new file mode 100644 index 0000000000..ad99d8cb9d --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/[teamId]/statistics/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
statistics
+} diff --git a/apps/web/src/app/portal/(authenticated)/dashboard/route.ts b/apps/web/src/app/portal/(authenticated)/dashboard/route.ts new file mode 100644 index 0000000000..6a84a45996 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/dashboard/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSessionData } from '../../_common/lib/client-config' + +export async function GET(request: NextRequest) { + const session = await getSessionData() + + // TODO: Implement this logic + const defaultTeamId = '1' + + return NextResponse.redirect( + `${request.nextUrl.protocol}/${request.nextUrl.host}/portal/dashboard/${defaultTeamId}`, + ) +} diff --git a/apps/web/src/app/portal/(authenticated)/layout.tsx b/apps/web/src/app/portal/(authenticated)/layout.tsx new file mode 100644 index 0000000000..d7418b8aff --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/layout.tsx @@ -0,0 +1,14 @@ +import { redirect } from 'next/navigation' +import { getSessionData } from '../_common/lib/client-config' + +export default async function Layout({ + children, +}: { children: React.ReactNode }) { + const authSession = await getSessionData() + + if (!authSession.isLoggedIn) { + redirect('/portal/login') + } + + return children +} diff --git a/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/resend-code/resend-code-action.ts b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/resend-code/resend-code-action.ts new file mode 100644 index 0000000000..90da63a574 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/resend-code/resend-code-action.ts @@ -0,0 +1,31 @@ +'use server' + +import { getUserServiceClient } from 'src/app/portal/(unauthenticated)/_common/lib/zitadel-client' +import { getSession } from 'src/app/portal/_common/lib/client-config' + +export async function resendCodeAction() { + const session = await getSession() + if (!session.isLoggedIn) { + return { error: 'Not logged in' } + } + + try { + const userServiceClient = getUserServiceClient() + await userServiceClient.resendEmailCode({ + $typeName: 'zitadel.user.v2.ResendEmailCodeRequest', + userId: session.user.id, + verification: { + case: 'sendCode', + value: { + $typeName: 'zitadel.user.v2.SendEmailVerificationCode', + urlTemplate: undefined, // TODO: Email template + }, + }, + }) + } catch (e) { + console.error(e) + return { error: 'Failed to resend code' } + } + + return { success: true } +} diff --git a/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/resend-code/resend-code-button.tsx b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/resend-code/resend-code-button.tsx new file mode 100644 index 0000000000..11939e8532 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/resend-code/resend-code-button.tsx @@ -0,0 +1,32 @@ +'use client' + +import { Button, Loader } from '@sushiswap/ui' +import { CheckMarkIcon } from '@sushiswap/ui/icons/CheckMarkIcon' +import { FailedMarkIcon } from '@sushiswap/ui/icons/FailedMarkIcon' +import { useMutation } from '@tanstack/react-query' +import { resendCodeAction } from './resend-code-action' + +export function ResendCodeButton() { + const { mutate, isSuccess, error, isPending } = useMutation({ + mutationKey: ['resend-code'], + mutationFn: async () => { + await resendCodeAction() + }, + }) + + return ( + + ) +} diff --git a/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-email-action.ts b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-email-action.ts new file mode 100644 index 0000000000..8de71f7cb9 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-email-action.ts @@ -0,0 +1,57 @@ +'use server' + +import { redirect } from 'next/navigation' +import { getUserServiceClient } from 'src/app/portal/(unauthenticated)/_common/lib/zitadel-client' +import { getSession } from 'src/app/portal/_common/lib/client-config' +import { verifyEmailFormSchema } from './verify-form-schema' + +export type FormState = + | { + error: string + } + | { + error: string + field: keyof (typeof verifyEmailFormSchema)['_output'] + } + | { + success: true + } + +export async function verifyEmailAction(data: FormData): Promise { + const session = await getSession() + if (!session.isLoggedIn) { + return { error: 'Not logged in' } + } + + const formData = Object.fromEntries(data.entries()) + const result = verifyEmailFormSchema.safeParse(formData) + + if (!result.success) { + return { error: 'Invalid form data' } + } + + try { + const userServiceClient = getUserServiceClient() + await userServiceClient.verifyEmail({ + $typeName: 'zitadel.user.v2.VerifyEmailRequest', + userId: session.user.id, + verificationCode: result.data.code, + }) + + session.user.email.isVerified = true + await session.save() + } catch (e) { + console.error(e) + if (e instanceof Error && 'code' in e && typeof e.code === 'number') { + switch (e.code) { + case 3: { + return { error: 'Invalid code', field: 'code' } + } + } + } + } + + redirect('/portal') + + return { success: true } +} diff --git a/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-form-schema.ts b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-form-schema.ts new file mode 100644 index 0000000000..b4aa10145b --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-form-schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const verifyEmailFormSchema = z.object({ + code: z + .string() + .trim() + .toUpperCase() + .length(6, { message: 'Code must be 6 characters long' }), +}) diff --git a/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-form.tsx b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-form.tsx new file mode 100644 index 0000000000..2cd3ca163c --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/verify/_common/ui/verify-form/verify-form.tsx @@ -0,0 +1,101 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { + Button, + FormControl, + FormField, + FormItem, + FormLabel, + TextField, + formClassnames, +} from '@sushiswap/ui' +import { useCallback, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { z } from 'zod' +import { ResendCodeButton } from './resend-code/resend-code-button' +import { verifyEmailAction } from './verify-email-action' +import { verifyEmailFormSchema } from './verify-form-schema' + +type VerifyFormValues = z.infer + +export function VerifyForm() { + const [globalErrorMsg, setGlobalErrorMsg] = useState(null) + + const form = useForm({ + defaultValues: { + code: '', + }, + mode: 'onBlur', + resolver: zodResolver(verifyEmailFormSchema), + }) + + const onSubmit = useCallback( + async (values: VerifyFormValues) => { + const formData = new FormData() + formData.append('code', values.code) + + const result = await verifyEmailAction(formData) + + if ('error' in result) { + if ('field' in result) { + form.setError(result.field, { message: result.error }) + } else { + setGlobalErrorMsg(result.error) + } + } + }, + [form.setError], + ) + + return ( +
+ +
+
+ ( + + + <> + Code + onChange(e.target.value)} + onBlur={onBlur} + className={formClassnames({ isError })} + disabled={form.formState.isSubmitting} + /> + + + + )} + /> +
+
+ + +
+ {globalErrorMsg && ( +
+ {globalErrorMsg} +
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/app/portal/(authenticated)/verify/api/set-email-verified/route.ts b/apps/web/src/app/portal/(authenticated)/verify/api/set-email-verified/route.ts new file mode 100644 index 0000000000..47599eb1cc --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/verify/api/set-email-verified/route.ts @@ -0,0 +1,30 @@ +import { redirect } from 'next/navigation' +import { getUserServiceClient } from 'src/app/portal/(unauthenticated)/_common/lib/zitadel-client' +import { getSession } from 'src/app/portal/_common/lib/client-config' + +export async function GET() { + const authSession = await getSession() + if (!authSession.isLoggedIn) { + return + } + + const userServiceClient = getUserServiceClient() + const result = await userServiceClient.getUserByID({ + $typeName: 'zitadel.user.v2.GetUserByIDRequest', + userId: authSession.user.id, + }) + if ( + !result.user || + result.user.type.case !== 'human' || + !result.user.type.value.email + ) { + // Should only happen if the server is down + throw new Error('Something went wrong') + } + if (result.user.type.value.email.isVerified) { + authSession.user.email.isVerified = true + await authSession.save() + } + + redirect('/portal') +} diff --git a/apps/web/src/app/portal/(authenticated)/verify/layout.tsx b/apps/web/src/app/portal/(authenticated)/verify/layout.tsx new file mode 100644 index 0000000000..f6e6df6d34 --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/verify/layout.tsx @@ -0,0 +1,38 @@ +import { Container } from '@sushiswap/ui' +import { redirect } from 'next/navigation' +import { getSession } from 'src/app/portal/_common/lib/client-config' +import { getUserServiceClient } from '../../(unauthenticated)/_common/lib/zitadel-client' + +export default async function Layout({ + children, +}: { children: React.ReactNode }) { + const authSession = await getSession() + + if (!authSession.isLoggedIn || authSession.user.email.isVerified) { + redirect('/portal') + } + + // Check if the user verified their email in the meantime + const userServiceClient = getUserServiceClient() + const result = await userServiceClient.getUserByID({ + $typeName: 'zitadel.user.v2.GetUserByIDRequest', + userId: authSession.user.id, + }) + if ( + !result.user || + result.user.type.case !== 'human' || + !result.user.type.value.email + ) { + // Should only happen if the server is down + throw new Error('Something went wrong') + } + if (result.user.type.value.email.isVerified) { + redirect('/portal/register/verify/api/set-email-verified') + } + + return ( + + {children} + + ) +} diff --git a/apps/web/src/app/portal/(authenticated)/verify/page.tsx b/apps/web/src/app/portal/(authenticated)/verify/page.tsx new file mode 100644 index 0000000000..6bf53bae2b --- /dev/null +++ b/apps/web/src/app/portal/(authenticated)/verify/page.tsx @@ -0,0 +1,10 @@ +import { VerifyForm } from './_common/ui/verify-form/verify-form' + +export default function Page() { + return ( +
+

Verify E-mail

+ +
+ ) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/_common/lib/create-zitadel-session.ts b/apps/web/src/app/portal/(unauthenticated)/_common/lib/create-zitadel-session.ts new file mode 100644 index 0000000000..0e12bc85a0 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/_common/lib/create-zitadel-session.ts @@ -0,0 +1,33 @@ +import ms from 'ms' +import { getSessionServiceClient } from 'src/app/portal/(unauthenticated)/_common/lib/zitadel-client' + +export async function createZitadelSession(body: { + email: string + password: string +}) { + const sessionServiceClient = getSessionServiceClient() + + return sessionServiceClient.createSession({ + $typeName: 'zitadel.session.v2.CreateSessionRequest', + lifetime: { + $typeName: 'google.protobuf.Duration', + seconds: BigInt(ms('7d')) / 1000n, + nanos: 0, + }, + metadata: {}, + checks: { + $typeName: 'zitadel.session.v2.Checks', + user: { + $typeName: 'zitadel.session.v2.CheckUser', + search: { + case: 'loginName', // e-mail, + value: body.email, + }, + }, + password: { + $typeName: 'zitadel.session.v2.CheckPassword', + password: body.password, + }, + }, + }) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/_common/lib/get-new-idp-intent.ts b/apps/web/src/app/portal/(unauthenticated)/_common/lib/get-new-idp-intent.ts new file mode 100644 index 0000000000..1dba20e22e --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/_common/lib/get-new-idp-intent.ts @@ -0,0 +1,40 @@ +import { headers } from 'next/headers' +import { authEnv } from 'src/app/portal/_common/lib/auth-env' +import { z } from 'zod' + +const newIdpIntentSchema = z.object({ + details: z.object({ + sequence: z.string(), + changeDate: z.string(), + resourceOwner: z.string(), + }), + authUrl: z.string(), +}) + +export async function getNewIdpIntent({ + idpId, +}: { idpId: string; type: 'login' | 'connect' }) { + const headers_ = await headers() + const host = headers_.get('Host')! + const proto = headers_.get('X-Forwarded-Proto') || 'https' + + const response = await fetch(`${authEnv.ZITADEL_ISSUER}/v2/idp_intents`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${authEnv.ZITADEL_SA_TOKEN}`, + }, + body: JSON.stringify({ + idpId, + urls: { + successUrl: `${proto}://${host}/portal/api/auth/callback`, + failureUrl: `${proto}://${host}/portal`, + }, + }), + }) + + const data = await response.json() + + return newIdpIntentSchema.parse(data) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/_common/lib/types.ts b/apps/web/src/app/portal/(unauthenticated)/_common/lib/types.ts new file mode 100644 index 0000000000..b2e88677eb --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/_common/lib/types.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export const zPassword = z + .string() + .min(8, { message: 'Minimum password length is 8 characters.' }) + .max(64, { message: 'Maximum password length is 64 characters.' }) + .regex(/[a-z]/, { + message: 'Use at least one lowercase letter.', + }) + .regex(/[A-Z]/, { + message: 'Use at least one uppercase letter.', + }) + .regex(/[0-9]/, { message: 'Use at least one digit.' }) diff --git a/apps/web/src/app/portal/(unauthenticated)/_common/lib/zitadel-client.ts b/apps/web/src/app/portal/(unauthenticated)/_common/lib/zitadel-client.ts new file mode 100644 index 0000000000..6735c49b05 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/_common/lib/zitadel-client.ts @@ -0,0 +1,40 @@ +import { createServerTransport } from '@zitadel/client/node' +import { + createSessionServiceClient, + createUserServiceClient, +} from '@zitadel/client/v2' +import { authEnv } from 'src/app/portal/_common/lib/auth-env' + +let serverTransport: ReturnType | undefined + +export function getServerTransport() { + if (!serverTransport) { + serverTransport = createServerTransport(authEnv.ZITADEL_SA_TOKEN, { + baseUrl: authEnv.ZITADEL_ISSUER, + }) + } + + return serverTransport +} + +let userServiceClient: ReturnType | undefined + +export function getUserServiceClient() { + if (!userServiceClient) { + userServiceClient = createUserServiceClient(getServerTransport()) + } + + return userServiceClient +} + +let sessionServiceClient: + | ReturnType + | undefined + +export function getSessionServiceClient() { + if (!sessionServiceClient) { + sessionServiceClient = createSessionServiceClient(getServerTransport()) + } + + return sessionServiceClient +} diff --git a/apps/web/src/app/portal/(unauthenticated)/_common/ui/github-button.tsx b/apps/web/src/app/portal/(unauthenticated)/_common/ui/github-button.tsx new file mode 100644 index 0000000000..731a0edcc6 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/_common/ui/github-button.tsx @@ -0,0 +1,33 @@ +import { Button } from '@sushiswap/ui' +import { GithubIcon } from '@sushiswap/ui/icons/GithubIcon' +import { getNewIdpIntent } from '../lib/get-new-idp-intent' + +export const dynamic = false + +export async function GithubButton({ + text, + type, + disabled = false, +}: { text: string; type: 'login' | 'connect'; disabled?: boolean }) { + const idpIntent = await getNewIdpIntent({ idpId: '299270310144770125', type }) + + const Comp = ( + + ) + + if (disabled) return Comp + + return ( + + {Comp} + + ) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/_common/ui/google-button.tsx b/apps/web/src/app/portal/(unauthenticated)/_common/ui/google-button.tsx new file mode 100644 index 0000000000..f7bda8a3f3 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/_common/ui/google-button.tsx @@ -0,0 +1,38 @@ +import { Button } from '@sushiswap/ui' +import { GoogleIcon } from '@sushiswap/ui/icons/GoogleIcon' +import { getNewIdpIntent } from '../lib/get-new-idp-intent' + +export const dynamic = false + +export async function GoogleButton({ + text, + type, + disabled = false, +}: { text: string; type: 'login' | 'connect'; disabled?: boolean }) { + const idpIntent = await getNewIdpIntent({ idpId: '299271776439894093', type }) + + const Comp = ( + + ) + + if (disabled) return Comp + + return ( + + {Comp} + + ) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/get-idp-intent.ts b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/get-idp-intent.ts new file mode 100644 index 0000000000..a47cb0242d --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/get-idp-intent.ts @@ -0,0 +1,122 @@ +import { authEnv } from 'src/app/portal/_common/lib/auth-env' +import { z } from 'zod' + +const googleSchema = z.object({ + User: z.object({ + email: z.string(), + given_name: z.string(), + name: z.string(), + }), +}) + +const githubSchema = z.object({ + email: z.string(), + name: z.string(), +}) + +const schema = z + .object({ + details: z.object({ + sequence: z.string(), + changeDate: z.string(), + resourceOwner: z.string(), + }), + idpInformation: z.object({ + oauth: z + .object({ + accessToken: z.string(), + idToken: z.string().optional(), + }) + .nullable(), + ldap: z.object({}).optional(), + saml: z.object({}).optional(), + idpId: z.string(), + userId: z.string(), + userName: z.string(), + rawInformation: googleSchema.or(githubSchema.or(z.object({}))), + }), + userId: z.string().optional(), + }) + .transform((data) => { + const rawInfo = data.idpInformation.rawInformation + + let userData: + | { + email: string + familyName: string + givenName: string + name: string + } + | undefined = undefined + + // Google + if ('User' in rawInfo) { + userData = { + email: rawInfo.User.email, + familyName: 'Google', + givenName: rawInfo.User.given_name, + name: rawInfo.User.name, + } + } + + // Github + if ('email' in rawInfo) { + userData = { + email: rawInfo.email, + familyName: 'GitHub', + givenName: rawInfo.name, + name: rawInfo.name, + } + } + + if (!userData) { + throw new Error('Invalid raw information') + } + + return { + details: { + sequence: data.details.sequence, + changeDate: data.details.changeDate, + resourceOwner: data.details.resourceOwner, + }, + idpInformation: { + oauth: data.idpInformation.oauth, + ldap: data.idpInformation.ldap, + saml: data.idpInformation.saml, + idpId: data.idpInformation.idpId, + userId: data.idpInformation.userId, + userName: data.idpInformation.userName, + rawInformation: userData, + }, + userId: data.userId, + } + }) + +export type IdpIntent = z.infer + +export async function getIdpIntent(id: string, token: string) { + const response = await fetch( + `${authEnv.ZITADEL_ISSUER}/v2/idp_intents/${id}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${authEnv.ZITADEL_SA_TOKEN}`, + }, + body: JSON.stringify({ + idpIntentToken: token, + }), + }, + ) + + const data = await response.json() + + const result = schema.safeParse(data) + + if (!result.success) { + throw new Error(`Couldn't get intent`) + } + + return result.data +} diff --git a/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/get-user-by-id.ts b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/get-user-by-id.ts new file mode 100644 index 0000000000..e7b0bddf39 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/get-user-by-id.ts @@ -0,0 +1,48 @@ +import { authEnv } from 'src/app/portal/_common/lib/auth-env' +import { z } from 'zod' + +// Not exhaustive +// https://zitadel.com/docs/apis/resources/user_service_v2/user-service-get-user-by-id +const schema = z.object({ + user: z.object({ + userId: z.string(), + state: z.enum([ + 'USER_STATE_UNSPECIFIED', + 'USER_STATE_ACTIVE', + 'USER_STATE_INACTIVE', + 'USER_STATE_DELETED', + 'USER_STATE_LOCKED', + 'USER_STATE_INITIAL', + ]), + username: z.string(), + loginNames: z.array(z.string()), + preferredLoginName: z.string(), + human: z.object({ + email: z.object({ + email: z.string(), + isVerified: z.boolean(), + }), + }), + }), +}) + +export async function getUserById(id: string) { + const response = await fetch(`${authEnv.ZITADEL_ISSUER}/v2/users/${id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${authEnv.ZITADEL_SA_TOKEN}`, + }, + }) + + const data = await response.json() + + const result = schema.safeParse(data) + + if (!result.success) { + throw new Error(`Couldn't fetch user data`) + } + + return result.data.user +} diff --git a/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/login.ts b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/login.ts new file mode 100644 index 0000000000..531df80f0b --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/login.ts @@ -0,0 +1,45 @@ +import { authEnv } from 'src/app/portal/_common/lib/auth-env' +import { z } from 'zod' + +const schema = z.object({ + sessionId: z.string(), + sessionToken: z.string(), +}) + +interface Login { + userId: string + idpIntentId: string + idpIntentToken: string +} + +export async function login(params: Login) { + const response = await fetch(`${authEnv.ZITADEL_ISSUER}/v2/sessions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${authEnv.ZITADEL_SA_TOKEN}`, + }, + body: JSON.stringify({ + checks: { + user: { + userId: params.userId, + }, + idpIntent: { + idpIntentId: params.idpIntentId, + idpIntentToken: params.idpIntentToken, + }, + }, + }), + }) + + const data = await response.json() + + const result = schema.safeParse(data) + + if (!result.success) { + throw new Error('Login failed') + } + + return result.data +} diff --git a/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/register.ts b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/register.ts new file mode 100644 index 0000000000..d0f1441938 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/lib/register.ts @@ -0,0 +1,45 @@ +import { authEnv } from 'src/app/portal/_common/lib/auth-env' +import { z } from 'zod' +import { IdpIntent } from './get-idp-intent' + +const schema = z.object({ + userId: z.string(), +}) + +export async function register(idpIntent: IdpIntent) { + const respose = await fetch(`${authEnv.ZITADEL_ISSUER}/v2/users/human`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${authEnv.ZITADEL_SA_TOKEN}`, + }, + body: JSON.stringify({ + profile: { + givenName: idpIntent.idpInformation.rawInformation.givenName, + familyName: idpIntent.idpInformation.rawInformation.familyName, + }, + email: { + email: idpIntent.idpInformation.rawInformation.email, + isVerified: true, + }, + idpLinks: [ + { + idpId: idpIntent.idpInformation.idpId, + userId: idpIntent.idpInformation.userId, + userName: idpIntent.idpInformation.rawInformation.email, + }, + ], + }), + }) + + const registrationData = await respose.json() + + const result = schema.safeParse(registrationData) + + if (!result.success) { + throw new Error('Registration failed') + } + + return result.data +} diff --git a/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/route.ts b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/route.ts new file mode 100644 index 0000000000..f71ae82a45 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/api/auth/callback/route.ts @@ -0,0 +1,89 @@ +import { redirect } from 'next/navigation' +import { type NextRequest } from 'next/server' +import { + createSession, + getSession, +} from 'src/app/portal/_common/lib/client-config' +import { z } from 'zod' +import { getSessionServiceClient } from '../../../_common/lib/zitadel-client' +import { getIdpIntent } from './lib/get-idp-intent' +import { getUserById } from './lib/get-user-by-id' +import { login } from './lib/login' +import { register } from './lib/register' + +const schema = z.object({ + id: z.string(), + token: z.string(), + user: z.string().nullable(), +}) + +async function GET(req: NextRequest) { + const url = new URL(req.url) + + const result = schema.safeParse({ + id: url.searchParams.get('id'), + token: url.searchParams.get('token'), + user: url.searchParams.get('user'), + }) + + if (!result.success) { + return new Response(JSON.stringify(result.error, null, 2), { status: 400 }) + } + + const data = result.data + let email: string | undefined + + if (!data.user) { + // Register + const idpIntent = await getIdpIntent(data.id, data.token) + const { userId } = await register(idpIntent) + data.user = userId + email = idpIntent.idpInformation.rawInformation.email + } + + const session = await login({ + userId: data.user, + idpIntentId: data.id, + idpIntentToken: data.token, + }) + + if (!email) { + const user = await getUserById(data.user) + if (user.state !== 'USER_STATE_ACTIVE') { + return new Response('User is not active', { status: 400 }) + } + + email = user.human.email.email + } + + const previousSession = await getSession() + let logoutP: Promise | null = null + if (previousSession.isLoggedIn) { + const sessionServiceClient = getSessionServiceClient() + logoutP = sessionServiceClient.deleteSession({ + $typeName: 'zitadel.session.v2.DeleteSessionRequest', + sessionId: previousSession.session.id, + sessionToken: previousSession.session.token, + }) + } + + await createSession({ + session: { + id: session.sessionId, + token: session.sessionToken, + }, + user: { + id: data.user, + email: { + email, + isVerified: true, + }, + }, + }) + + await logoutP + + return redirect('/portal') +} + +export { GET } diff --git a/apps/web/src/app/portal/(unauthenticated)/layout.tsx b/apps/web/src/app/portal/(unauthenticated)/layout.tsx new file mode 100644 index 0000000000..5f7bef2fc5 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/layout.tsx @@ -0,0 +1,14 @@ +import { redirect } from 'next/navigation' +import { getSessionData } from '../_common/lib/client-config' + +export default async function Layout({ + children, +}: { children: React.ReactNode }) { + const authSession = await getSessionData() + + if (authSession.isLoggedIn) { + redirect('/portal') + } + + return children +} diff --git a/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-action.ts b/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-action.ts new file mode 100644 index 0000000000..8265889ed0 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-action.ts @@ -0,0 +1,131 @@ +'use server' + +import { isRedirectError } from 'next/dist/client/components/redirect-error' +import { redirect } from 'next/navigation' +import { createSession } from 'src/app/portal/_common/lib/client-config' +import { isPromiseRejected } from 'sushi' +import { createZitadelSession } from '../../../_common/lib/create-zitadel-session' +import { getUserServiceClient } from '../../../_common/lib/zitadel-client' +import { loginFormSchema } from './login-form-schema' + +export type FormState = + | { + error: string + } + | { + error: string + field: keyof (typeof loginFormSchema)['_output'] + } + | { + success: true + } + +export async function loginAction(data: FormData): Promise { + const formData = Object.fromEntries(data.entries()) + const result = loginFormSchema.safeParse(formData) + + if (!result.success) { + return { error: 'Invalid form data' } + } + + try { + const [session, user] = await Promise.allSettled([ + createZitadelSession(result.data), + fetchZitadelUser(result.data.email), + ]) + + if (isPromiseRejected(session)) { + const e = session.reason + if (e instanceof Error && 'code' in e && typeof e.code === 'number') { + switch (e.code) { + case 3: + return { error: 'Invalid password', field: 'password' } + case 5: + return { error: 'Email not found', field: 'email' } + } + } + return { error: 'An unknown error occured' } + } + + if (isPromiseRejected(user)) { + return { error: 'Failed to fetch user' } + } + + await createSession({ + session: { + id: session.value.sessionId, + token: session.value.sessionToken, + }, + user: { + id: user.value.userId, + email: { + isVerified: user.value.type.value.email.isVerified, + email: user.value.type.value.email.email, + }, + }, + }) + + if (!user.value.type.value.email.isVerified) { + redirect('/portal/register/verify') + } else { + redirect('/portal') + } + + return { success: true } + } catch (e) { + if (isRedirectError(e)) { + throw e + } + + console.error(e) + return { error: 'An unknown error occured' } + } +} + +async function fetchZitadelUser(email: string) { + const userServiceClient = getUserServiceClient() + + return userServiceClient + .listUsers( + { + queries: [ + { + $typeName: 'zitadel.user.v2.SearchQuery', + query: { + case: 'emailQuery', + value: { + $typeName: 'zitadel.user.v2.EmailQuery', + method: 0, // Equals + emailAddress: email, + }, + }, + }, + ], + }, + {}, + ) + .then((res) => { + if (res.result.length > 0) { + const user = res.result[0] + if (user.type.case !== 'human') { + throw new Error('Machine users are not allowed') + } + + if (!user.type.value.email) { + throw new Error('User has no email') + } + + return { + ...user, + type: { + ...user.type, + value: { + ...user.type.value, + email: user.type.value.email, + }, + }, + } + } + throw new Error('User not found') + }) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-form-schema.ts b/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-form-schema.ts new file mode 100644 index 0000000000..10dde39c93 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-form-schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' +import { zPassword } from '../../../_common/lib/types' + +export const loginFormSchema = z.object({ + email: z.string().email(), + password: zPassword, +}) diff --git a/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-form.tsx b/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-form.tsx new file mode 100644 index 0000000000..ead8849f26 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/login/_common/ui/login-form.tsx @@ -0,0 +1,125 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { + Button, + FormControl, + FormField, + FormItem, + FormLabel, + TextField, + formClassnames, +} from '@sushiswap/ui' +import { useCallback, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { z } from 'zod' +import { loginAction } from './login-action' +import { loginFormSchema } from './login-form-schema' + +type LoginFormValues = z.infer + +export function LoginForm() { + const [globalErrorMsg, setGlobalErrorMsg] = useState(null) + + const form = useForm>({ + defaultValues: { + email: '', + password: '', + }, + mode: 'onBlur', + resolver: zodResolver(loginFormSchema), + }) + + const onSubmit = useCallback( + async (values: LoginFormValues) => { + const formData = new FormData() + formData.append('email', values.email) + formData.append('password', values.password) + + const result = await loginAction(formData) + + if ('error' in result) { + if ('field' in result) { + form.setError(result.field, { message: result.error }) + } else { + setGlobalErrorMsg(result.error) + } + } + }, + [form.setError], + ) + + return ( +
+ +
+
+ ( + + + <> + E-mail + onChange(e.target.value)} + onBlur={onBlur} + className={formClassnames({ isError })} + disabled={form.formState.isSubmitting} + /> + + + + )} + /> + ( + + + <> + Password + onChange(e.target.value)} + onBlur={onBlur} + className={formClassnames({ isError })} + disabled={form.formState.isSubmitting} + /> + + + + )} + /> +
+ + {globalErrorMsg && ( +
+ {globalErrorMsg} +
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/login/layout.tsx b/apps/web/src/app/portal/(unauthenticated)/login/layout.tsx new file mode 100644 index 0000000000..e90ecca339 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/login/layout.tsx @@ -0,0 +1,9 @@ +import { Container } from '@sushiswap/ui' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/login/page.tsx b/apps/web/src/app/portal/(unauthenticated)/login/page.tsx new file mode 100644 index 0000000000..ae47e7a902 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/login/page.tsx @@ -0,0 +1,33 @@ +import { Separator } from '@sushiswap/ui' +import Link from 'next/link' +import { GithubButton } from '../_common/ui/github-button' +import { GoogleButton } from '../_common/ui/google-button' +import { LoginForm } from './_common/ui/login-form' + +export default function Page() { + return ( +
+

Sign In

+ +
+ +
+ {`Don't have an account yet?`} + + {`Sign Up`} + +
+
+ +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-action.ts b/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-action.ts new file mode 100644 index 0000000000..1aba93eef8 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-action.ts @@ -0,0 +1,135 @@ +'use server' + +import { CreateSessionResponse } from '@zitadel/proto/zitadel/session/v2/session_service_pb' +import { AddHumanUserResponse } from '@zitadel/proto/zitadel/user/v2/user_service_pb' +import { redirect } from 'next/navigation' +import { createZitadelSession } from 'src/app/portal/(unauthenticated)/_common/lib/create-zitadel-session' +import { getUserServiceClient } from 'src/app/portal/(unauthenticated)/_common/lib/zitadel-client' +import { createSession } from 'src/app/portal/_common/lib/client-config' +import { z } from 'zod' +import { registerFormSchema } from './register-form-schema' + +export type FormState = + | { + error: string + } + | { + error: string + field: keyof (typeof registerFormSchema)['_output'] + } + | { + success: true + } + +export async function registerAction(data: FormData): Promise { + const formData = Object.fromEntries(data.entries()) + const result = registerFormSchema.safeParse(formData) + + if (!result.success) { + return { error: 'Invalid form data' } + } + + // Check for existing users (with the same email) + try { + const existingUsers = await fetchZitadelUsers(result.data.email) + if (existingUsers.result.length > 0) { + return { + error: 'An user with this e-mail already exists', + field: 'email', + } + } + } catch (e) { + console.error(e) + return { error: 'Failed to check for existing users' } + } + + // Create a new user + let user: AddHumanUserResponse + try { + user = await createZitadelUser(result.data) + } catch (e) { + console.error(e) + return { error: 'Failed to create user' } + } + + // Create a session for the user + let session: CreateSessionResponse + try { + session = await createZitadelSession({ + email: result.data.email, + password: result.data.password, + }) + } catch (e) { + console.error(e) + return { error: 'Failed to create session' } + } + + // Create a session in the app (sets cookies) + await createSession({ + session: { + id: session.sessionId, + token: session.sessionToken, + }, + user: { + id: user.userId, + email: { + email: result.data.email, + isVerified: false, + }, + }, + }) + + redirect('/portal/register/verify') + + return { success: true } +} + +async function fetchZitadelUsers(email: string) { + const userServiceClient = getUserServiceClient() + const existingUsers = await userServiceClient.listUsers( + { + queries: [ + { + $typeName: 'zitadel.user.v2.SearchQuery', + query: { + case: 'emailQuery', + value: { + $typeName: 'zitadel.user.v2.EmailQuery', + method: 0, // Equals + emailAddress: email, + }, + }, + }, + ], + }, + {}, + ) + + return existingUsers +} + +async function createZitadelUser(data: z.infer) { + const userServiceClient = getUserServiceClient() + const user = await userServiceClient.addHumanUser({ + email: { + email: data.email, + verification: { + case: 'sendCode', + value: {}, + // TODO: Template + }, + }, + profile: { + givenName: '-', + familyName: '-', + }, + passwordType: { + case: 'password', + value: { + password: data.password, + }, + }, + }) + + return user +} diff --git a/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-form-schema.ts b/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-form-schema.ts new file mode 100644 index 0000000000..56a62e902d --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-form-schema.ts @@ -0,0 +1,24 @@ +import { zPassword } from 'src/app/portal/(unauthenticated)/_common/lib/types' +import { z } from 'zod' + +export const registerFormSchema = z + .object({ + email: z.string().email(), + password: zPassword, + passwordConfirmation: zPassword, + }) + .superRefine((data, ctx) => { + if ( + data.password && + data.passwordConfirmation && + data.password !== data.passwordConfirmation + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Passwords do not match', + path: ['passwordConfirmation'], + }) + } + + return data + }) diff --git a/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-form.tsx b/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-form.tsx new file mode 100644 index 0000000000..fb7b7a9694 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/register/_common/ui/register-form.tsx @@ -0,0 +1,164 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { + Button, + FormControl, + FormField, + FormItem, + FormLabel, + TextField, + formClassnames, +} from '@sushiswap/ui' +import { useCallback, useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { z } from 'zod' +import { registerAction } from './register-action' +import { registerFormSchema } from './register-form-schema' + +type RegisterFormValues = z.infer + +export function RegisterForm() { + const [globalErrorMsg, setGlobalErrorMsg] = useState(null) + + const form = useForm({ + defaultValues: { + email: '', + password: '', + passwordConfirmation: '', + }, + mode: 'onBlur', + resolver: zodResolver(registerFormSchema), + }) + + useEffect(() => { + if (form.getValues('password')) { + form.trigger(['password']) + } + if (form.getValues('passwordConfirmation')) { + form.trigger(['passwordConfirmation']) + } + }, [ + form.getValues, + form.trigger, + ...form.watch(['password', 'passwordConfirmation']), + ]) + + const onSubmit = useCallback( + async (values: RegisterFormValues) => { + const formData = new FormData() + formData.append('email', values.email) + formData.append('password', values.password) + formData.append('passwordConfirmation', values.passwordConfirmation) + + const result = await registerAction(formData) + + if ('error' in result) { + if ('field' in result) { + form.setError(result.field, { message: result.error }) + } else { + setGlobalErrorMsg(result.error) + } + } + }, + [form.setError], + ) + + const isPending = form.formState.isSubmitting + + return ( +
+ +
+
+ ( + + + <> + E-mail + onChange(e.target.value)} + onBlur={onBlur} + className={formClassnames({ isError })} + disabled={isPending} + /> + + + + )} + /> + ( + + + <> + Password + onChange(e.target.value)} + onBlur={onBlur} + className={formClassnames({ isError })} + disabled={isPending} + /> + + + + )} + /> + ( + + + <> + Repeat Password + onChange(e.target.value)} + onBlur={onBlur} + className={formClassnames({ isError })} + disabled={isPending} + /> + + + + )} + /> +
+ + {globalErrorMsg && ( +
+ {globalErrorMsg} +
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/register/layout.tsx b/apps/web/src/app/portal/(unauthenticated)/register/layout.tsx new file mode 100644 index 0000000000..e90ecca339 --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/register/layout.tsx @@ -0,0 +1,9 @@ +import { Container } from '@sushiswap/ui' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/src/app/portal/(unauthenticated)/register/page.tsx b/apps/web/src/app/portal/(unauthenticated)/register/page.tsx new file mode 100644 index 0000000000..942fdec04d --- /dev/null +++ b/apps/web/src/app/portal/(unauthenticated)/register/page.tsx @@ -0,0 +1,20 @@ +import { Separator } from '@sushiswap/ui' +import { GithubButton } from '../_common/ui/github-button' +import { GoogleButton } from '../_common/ui/google-button' +import { RegisterForm } from './_common/ui/register-form' + +export default function Page() { + return ( +
+

Register

+ + + +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/app/portal/_common/lib/auth-env.ts b/apps/web/src/app/portal/_common/lib/auth-env.ts new file mode 100644 index 0000000000..3c1fb28896 --- /dev/null +++ b/apps/web/src/app/portal/_common/lib/auth-env.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const authEnv = z + .object({ + AUTH_SESSION_SECRET: z.string(), + ZITADEL_ISSUER: z.string(), + ZITADEL_CLIENT_ID: z.string(), + ZITADEL_CLIENT_SECRET: z.string(), + ZITADEL_SA_TOKEN: z.string(), + }) + .parse(process.env) diff --git a/apps/web/src/app/portal/_common/lib/client-config.ts b/apps/web/src/app/portal/_common/lib/client-config.ts new file mode 100644 index 0000000000..66b5d5fc66 --- /dev/null +++ b/apps/web/src/app/portal/_common/lib/client-config.ts @@ -0,0 +1,67 @@ +import { IronSession, SessionOptions, getIronSession } from 'iron-session' +import { cookies } from 'next/headers' +import { authEnv } from './auth-env' + +export type ActiveSession = { + isLoggedIn: true + session: { + id: string + token: string + } + user: { + id: string + email: { + email: string + isVerified: boolean + } + } +} + +export type Session = ActiveSession | { isLoggedIn: false } + +export const defaultSession: Session = { isLoggedIn: false } + +export const sessionOptions: SessionOptions = { + password: authEnv.AUTH_SESSION_SECRET, + cookieName: 'portal-session', + cookieOptions: { + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, + }, + ttl: 60 * 60 * 24 * 7, +} + +export async function createSession(data: Omit) { + const session = await getIronSession( + await cookies(), + sessionOptions, + ) + + session.isLoggedIn = true + session.session = data.session + session.user = data.user + + await session.save() +} + +export async function getSession< + T extends Partial = Session, +>(): Promise> { + const cookiez = await cookies() + const session = await getIronSession(cookiez, sessionOptions) + + if (!session.isLoggedIn) { + session.isLoggedIn = false + } + + return session +} + +export async function getSessionData< + T extends Partial = Session, +>(): Promise { + const session = await getSession() + + return JSON.parse(JSON.stringify(session)) +} diff --git a/apps/web/src/app/portal/_common/lib/logout-action.ts b/apps/web/src/app/portal/_common/lib/logout-action.ts new file mode 100644 index 0000000000..780e6534d5 --- /dev/null +++ b/apps/web/src/app/portal/_common/lib/logout-action.ts @@ -0,0 +1,33 @@ +'use server' + +import { redirect } from 'next/navigation' +import { getSession } from 'src/app/portal/_common/lib/client-config' +import { getSessionServiceClient } from '../../(unauthenticated)/_common/lib/zitadel-client' + +export async function logoutAction() { + const session = await getSession() + + if (!session.isLoggedIn) { + redirect('/portal') + return + } + + try { + const sessionServiceClient = getSessionServiceClient() + await sessionServiceClient.deleteSession( + { + $typeName: 'zitadel.session.v2.DeleteSessionRequest', + sessionId: session.session.id, + sessionToken: session.session.token, + }, + { headers: { Authorization: '' } }, + ) + } catch (e) { + console.error(e) + } + + session.destroy() + + redirect('/portal') + return +} diff --git a/apps/web/src/app/portal/_common/ui/auth-provider/auth-provider.tsx b/apps/web/src/app/portal/_common/ui/auth-provider/auth-provider.tsx new file mode 100644 index 0000000000..1c565b0948 --- /dev/null +++ b/apps/web/src/app/portal/_common/ui/auth-provider/auth-provider.tsx @@ -0,0 +1,33 @@ +'use client' + +import { createContext, useContext, useEffect, useState } from 'react' +import { Session } from '../../lib/client-config' + +const AuthContext = createContext({ isLoggedIn: false }) + +interface AuthProvider { + children: React.ReactNode + initialSession: Session +} + +export function AuthProvider({ initialSession, children }: AuthProvider) { + const [session, setSession] = useState(initialSession) + + useEffect(() => { + if (session !== initialSession) { + setSession(initialSession) + } + }, [session, initialSession]) + + return {children} +} + +export function useSession() { + const session = useContext(AuthContext) + + if (!session) { + throw new Error('useSession must be used within an AuthProvider') + } + + return session +} diff --git a/apps/web/src/app/portal/_common/ui/header/header-profile.tsx b/apps/web/src/app/portal/_common/ui/header/header-profile.tsx new file mode 100644 index 0000000000..a74525b4c1 --- /dev/null +++ b/apps/web/src/app/portal/_common/ui/header/header-profile.tsx @@ -0,0 +1,48 @@ +'use client' + +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, + Separator, +} from '@sushiswap/ui' +import Link from 'next/link' +import { logoutAction } from 'src/app/portal/_common/lib/logout-action' +import { useSession } from '../auth-provider/auth-provider' + +export function HeaderProfile() { + const session = useSession() + + if (!session.isLoggedIn) { + return ( +
+ + + + + + +
+ ) + } + + return ( + + + + + +
Dunno something
+ +
+ +
+
+
+ ) +} diff --git a/apps/web/src/app/portal/_common/ui/header/header.tsx b/apps/web/src/app/portal/_common/ui/header/header.tsx new file mode 100644 index 0000000000..9956895784 --- /dev/null +++ b/apps/web/src/app/portal/_common/ui/header/header.tsx @@ -0,0 +1,54 @@ +import { Navigation, NavigationElementType } from '@sushiswap/ui' +import { SushiIcon } from '@sushiswap/ui/icons/SushiIcon' +import { getSessionData } from '../../lib/client-config' +import { HeaderProfile } from './header-profile' + +export async function Header() { + const session = await getSessionData() + + return ( +
+ , + href: '/portal', + show: 'everywhere', + type: NavigationElementType.Custom, + }, + ...(session.isLoggedIn && session.user.email.isVerified + ? [ + { + title: 'Dashboard', + href: '/portal/dashboard', + show: 'everywhere', + type: NavigationElementType.Single, + } as const, + ] + : []), + { + title: 'Pricing', + href: '/portal/pricing', + show: 'everywhere', + type: NavigationElementType.Single, + }, + { + title: 'Docs', + href: 'https://docs.sushi.com', + show: 'everywhere', + type: NavigationElementType.Single, + }, + { + title: 'Support', + href: 'https://sushi.com', + show: 'everywhere', + type: NavigationElementType.Single, + }, + ]} + rightElement={} + /> +
+ ) +} diff --git a/apps/web/src/app/portal/layout.tsx b/apps/web/src/app/portal/layout.tsx new file mode 100644 index 0000000000..abb0928163 --- /dev/null +++ b/apps/web/src/app/portal/layout.tsx @@ -0,0 +1,16 @@ +import { getSessionData } from './_common/lib/client-config' +import { Header } from './_common/ui/header/header' +import { Providers } from './providers' + +export default async function Layout({ + children, +}: { children: React.ReactNode }) { + const authSession = await getSessionData() + + return ( + +
+ {children} + + ) +} diff --git a/apps/web/src/app/portal/middleware.ts b/apps/web/src/app/portal/middleware.ts new file mode 100644 index 0000000000..1eefc51f76 --- /dev/null +++ b/apps/web/src/app/portal/middleware.ts @@ -0,0 +1,22 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSessionData } from './_common/lib/client-config' + +export async function portalMiddleware(request: NextRequest) { + const session = await getSessionData() + + if (!session.isLoggedIn) { + return + } + + if (!session.user.email.isVerified) { + console.log(request.nextUrl) + if (request.nextUrl.pathname !== '/portal/register/verify') { + console.log('redirect') + return NextResponse.redirect( + `${request.nextUrl.protocol}/${request.nextUrl.host}/portal/register/verify`, + ) + } + } + + return +} diff --git a/apps/web/src/app/portal/page.tsx b/apps/web/src/app/portal/page.tsx new file mode 100644 index 0000000000..1506db0a74 --- /dev/null +++ b/apps/web/src/app/portal/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return <>A +} diff --git a/apps/web/src/app/portal/pricing/layout.tsx b/apps/web/src/app/portal/pricing/layout.tsx new file mode 100644 index 0000000000..119abf12e9 --- /dev/null +++ b/apps/web/src/app/portal/pricing/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/web/src/app/portal/pricing/page.tsx b/apps/web/src/app/portal/pricing/page.tsx new file mode 100644 index 0000000000..f2b49ddee9 --- /dev/null +++ b/apps/web/src/app/portal/pricing/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Pricing
+} diff --git a/apps/web/src/app/portal/providers.tsx b/apps/web/src/app/portal/providers.tsx new file mode 100644 index 0000000000..2d716b7548 --- /dev/null +++ b/apps/web/src/app/portal/providers.tsx @@ -0,0 +1,18 @@ +'use client' + +import { QueryClientProvider } from 'src/providers/query-client-provider' +import { Session } from './_common/lib/client-config' +import { AuthProvider } from './_common/ui/auth-provider/auth-provider' + +interface Providers { + children: React.ReactNode + authSession: Session +} + +export function Providers({ children, authSession }: Providers) { + return ( + + {children} + + ) +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index a8fbb05eed..73443c64da 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { ChainKey, getEvmChainInfo } from 'sushi/chain' import { isSushiSwapChainId } from 'sushi/config' +import { portalMiddleware } from './app/portal/middleware' export const config = { matcher: [ @@ -20,12 +21,17 @@ export const config = { '/:chainId/positions/:path*', '/:chainId/migrate', '/:chainId/rewards', + '/portal/:path*', ], } export async function middleware(req: NextRequest) { const { pathname, searchParams, search } = req.nextUrl + if (pathname === 'portal' || pathname.startsWith('/portal/')) { + return portalMiddleware(req) + } + if ( pathname === '/explore' || pathname === '/pools' || diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx index c2ae01115e..c9868b6db6 100644 --- a/packages/ui/src/components/form.tsx +++ b/packages/ui/src/components/form.tsx @@ -115,24 +115,29 @@ const FormItem = React.forwardRef< FormItem.displayName = 'FormItem' const FormLabel = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { const { error, formItemId } = useFormField() return ( -