diff --git a/apps/platform/trpc/routers/authRouter/passkeyRouter.ts b/apps/platform/trpc/routers/authRouter/passkeyRouter.ts
index 82e5edae..669f5fd8 100644
--- a/apps/platform/trpc/routers/authRouter/passkeyRouter.ts
+++ b/apps/platform/trpc/routers/authRouter/passkeyRouter.ts
@@ -37,7 +37,7 @@ export const passkeyRouter = router({
username: zodSchemas.username()
})
)
- .query(async ({ input, ctx }) => {
+ .mutation(async ({ input, ctx }) => {
const { db } = ctx;
const { username } = input;
const { available, error } = await validateUsername(db, input.username);
diff --git a/apps/platform/trpc/routers/authRouter/passwordRouter.ts b/apps/platform/trpc/routers/authRouter/passwordRouter.ts
index 5bb3c953..425ef5e8 100644
--- a/apps/platform/trpc/routers/authRouter/passwordRouter.ts
+++ b/apps/platform/trpc/routers/authRouter/passwordRouter.ts
@@ -24,6 +24,50 @@ import { ms } from '@u22n/utils/ms';
import { ratelimiter } from '~platform/trpc/ratelimit';
export const passwordRouter = router({
+ signUpWithPassword: publicProcedure
+ .unstable_concat(turnstileProcedure)
+ .use(ratelimiter({ limit: 10, namespace: 'signUp.password' }))
+ .input(
+ z.object({
+ username: zodSchemas.username(),
+ password: strongPasswordSchema
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { password, username } = input;
+ const { db, event } = ctx;
+
+ const { accountId, publicId } = await db.transaction(async (tx) => {
+ const { available, error } = await validateUsername(tx, username);
+ if (!available) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: `Username Error : ${error}`
+ });
+ }
+ const passwordHash = await new Argon2id().hash(password);
+ const publicId = typeIdGenerator('account');
+
+ const newUser = await tx.insert(accounts).values({
+ username,
+ publicId,
+ passwordHash
+ });
+
+ return {
+ accountId: Number(newUser.insertId),
+ publicId
+ };
+ });
+
+ await createLuciaSessionCookie(event, {
+ accountId,
+ username,
+ publicId
+ });
+
+ return { success: true };
+ }),
signUpWithPassword2FA: publicProcedure
.unstable_concat(turnstileProcedure)
.use(ratelimiter({ limit: 10, namespace: 'signUp.password' }))
diff --git a/apps/platform/trpc/routers/authRouter/signupRouter.ts b/apps/platform/trpc/routers/authRouter/signupRouter.ts
index e330494f..9d1c3886 100644
--- a/apps/platform/trpc/routers/authRouter/signupRouter.ts
+++ b/apps/platform/trpc/routers/authRouter/signupRouter.ts
@@ -52,9 +52,7 @@ export const signupRouter = router({
username: zodSchemas.username()
})
)
- .query(async ({ ctx, input }) => {
- return await validateUsername(ctx.db, input.username);
- }),
+ .query(({ ctx, input }) => validateUsername(ctx.db, input.username)),
checkPasswordStrength: publicProcedure
.use(ratelimiter({ limit: 50, namespace: 'check.password' }))
.input(
diff --git a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts
index 84168e07..135ad67e 100644
--- a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts
+++ b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { router, accountProcedure } from '~platform/trpc/trpc';
import type { DBType } from '@u22n/database';
-import { eq, and } from '@u22n/database/orm';
+import { eq, and, like } from '@u22n/database/orm';
import {
orgs,
orgMembers,
@@ -56,7 +56,7 @@ export const crudRouter = router({
.string()
.min(5)
.max(64)
- .regex(/^[a-z0-9]*$/, {
+ .regex(/^[a-z0-9\-]*$/, {
message: 'Only lowercase letters and numbers'
})
})
@@ -65,6 +65,46 @@ export const crudRouter = router({
return await validateOrgShortcode(ctx.db, input.shortcode);
}),
+ generateOrgShortcode: accountProcedure
+ .input(
+ z.object({
+ orgName: z.string().min(5)
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const autoShortcode = input.orgName
+ .toLowerCase()
+ .replace(/[^a-z0-9\-]/g, '');
+ const existingOrgs = await ctx.db.query.orgs.findMany({
+ where: like(orgs.shortcode, `${autoShortcode}%`),
+ columns: {
+ shortcode: true
+ }
+ });
+
+ if (existingOrgs.length === 0) {
+ return { shortcode: autoShortcode.substring(0, 32) };
+ }
+ const existingShortcodeList = existingOrgs.map((org) => org.shortcode);
+ let currentSuffix = existingShortcodeList.length;
+ let retries = 0;
+ let newShortcode = `${autoShortcode.substring(0, 28)}-${currentSuffix}`;
+
+ while (existingShortcodeList.includes(newShortcode)) {
+ currentSuffix++;
+ newShortcode = `${autoShortcode.substring(0, 28)}-${currentSuffix}`;
+ retries++;
+ if (retries > 30) {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message:
+ 'Failed to generate unique shortcode, please type one manually'
+ });
+ }
+ }
+ return { shortcode: newShortcode };
+ }),
+
createNewOrg: accountProcedure
.input(
z.object({
diff --git a/apps/platform/trpc/routers/userRouter/profileRouter.ts b/apps/platform/trpc/routers/userRouter/profileRouter.ts
index 200d693b..2c67c438 100644
--- a/apps/platform/trpc/routers/userRouter/profileRouter.ts
+++ b/apps/platform/trpc/routers/userRouter/profileRouter.ts
@@ -116,23 +116,22 @@ export const profileRouter = router({
.input(
z.object({
profilePublicId: typeIdValidator('orgMemberProfile'),
- fName: z.string(),
- lName: z.string(),
- title: z.string(),
- blurb: z.string(),
- imageId: z.string().uuid().optional().nullable(),
- handle: z.string().min(2).max(20)
+ name: z.string(),
+ title: z.string().optional(),
+ blurb: z.string().optional(),
+ handle: z.string().min(2).max(20).optional()
})
)
.mutation(async ({ ctx, input }) => {
const { db, account } = ctx;
const accountId = account.id;
+ const [firstName, ...lastName] = input.name.split(' ');
await db
.update(orgMemberProfiles)
.set({
- firstName: input.fName,
- lastName: input.lName,
+ firstName,
+ lastName: lastName.join(' '),
title: input.title,
blurb: input.blurb,
handle: input.handle
diff --git a/apps/web/src/app/(login)/_components/passkey-login.tsx b/apps/web/src/app/(login)/_components/passkey-login.tsx
index a4fde947..a4b8b89f 100644
--- a/apps/web/src/app/(login)/_components/passkey-login.tsx
+++ b/apps/web/src/app/(login)/_components/passkey-login.tsx
@@ -40,6 +40,7 @@ export function PasskeyLoginButton() {
description: 'Redirecting you to create an organization'
});
router.push('/join/org');
+ return;
}
toast.success('Sign in successful!', {
diff --git a/apps/web/src/app/[orgShortcode]/settings/user/profile/page.tsx b/apps/web/src/app/[orgShortcode]/settings/user/profile/page.tsx
index 313c2079..3e45fc13 100644
--- a/apps/web/src/app/[orgShortcode]/settings/user/profile/page.tsx
+++ b/apps/web/src/app/[orgShortcode]/settings/user/profile/page.tsx
@@ -72,8 +72,7 @@ export default function Page() {
const { loading: saveLoading, run: saveProfile } = useLoading(async () => {
if (!initData) return;
await updateProfileApi.mutateAsync({
- fName: firstNameValue,
- lName: lastNameValue,
+ name: `${firstNameValue} ${lastNameValue}`,
blurb: bioValue,
title: titleValue,
handle: initData.profile.handle ?? '',
diff --git a/apps/web/src/app/join/_components/stepper.tsx b/apps/web/src/app/join/_components/stepper.tsx
index 13e659e9..3974003c 100644
--- a/apps/web/src/app/join/_components/stepper.tsx
+++ b/apps/web/src/app/join/_components/stepper.tsx
@@ -1,5 +1,5 @@
-import { Separator } from '@/src/components/shadcn-ui/separator';
import { cn } from '@/src/lib/utils';
+import { Check } from '@phosphor-icons/react';
export default function Stepper({
step,
@@ -9,15 +9,25 @@ export default function Stepper({
total: number;
}) {
return (
-
+
+ {`This is step ${step} of ${total}`}
{Array.from({ length: total }).map((_, i) => (
- i + 1 && 'bg-green-9 border-none text-white'
+ )}>
+ {step > i + 1 ? (
+
+ ) : (
+ i + 1
)}
- />
+
))}
);
diff --git a/apps/web/src/app/join/layout.tsx b/apps/web/src/app/join/layout.tsx
index a3165b86..ddc5c17b 100644
--- a/apps/web/src/app/join/layout.tsx
+++ b/apps/web/src/app/join/layout.tsx
@@ -2,12 +2,8 @@
export default function Page({ children }: { children: React.ReactNode }) {
return (
-
-
-
Let's Make your
- UnInbox
- {children}
-
+
+ {children}
);
}
diff --git a/apps/web/src/app/join/org/_components/create-org.tsx b/apps/web/src/app/join/org/_components/create-org.tsx
index 4c62cf68..74750ce0 100644
--- a/apps/web/src/app/join/org/_components/create-org.tsx
+++ b/apps/web/src/app/join/org/_components/create-org.tsx
@@ -1,189 +1,163 @@
'use client';
-import useLoading from '@/src/hooks/use-loading';
import { platform } from '@/src/lib/trpc';
import { Button } from '@/src/components/shadcn-ui/button';
-import {
- Dialog,
- DialogContent,
- DialogTrigger,
- DialogTitle,
- DialogClose
-} from '@/src/components/shadcn-ui/dialog';
-import { useDebounce } from '@uidotdev/usehooks';
-import { Check, Plus } from '@phosphor-icons/react';
-import { useEffect, useState } from 'react';
+import { IdentificationCard } from '@phosphor-icons/react';
+import { useMemo, useState } from 'react';
import { z } from 'zod';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Input } from '@/src/components/shadcn-ui/input';
import { cn } from '@/src/lib/utils';
+import { env } from '@/src/env';
+import { EditableText } from '@/src/components/shared/editable-text';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger
+} from '@/src/components/shadcn-ui/tooltip';
-export default function CreateOrgButton({
- hasInviteCode
-}: {
- hasInviteCode: boolean;
-}) {
+export function CreateOrg() {
const [orgName, setOrgName] = useState('');
const [orgShortcode, setOrgShortcode] = useState('');
const [customShortcode, setCustomShortcode] = useState(false);
const router = useRouter();
- const debouncedOrgShortcode = useDebounce(orgShortcode, 1000);
- const checkOrgShortcodeApi =
- platform.useUtils().org.crud.checkShortcodeAvailability;
- const createOrgApi = platform.org.crud.createNewOrg.useMutation();
-
- const {
- loading: orgShortcodeDataLoading,
- data: orgShortcodeData,
- error: orgShortcodeError,
- run: checkOrgShortcode
- } = useLoading(async (signal) => {
- if (!debouncedOrgShortcode) return;
- const parsed = z
+ const [shortcodeValid, shortcodeError] = useMemo(() => {
+ const { success, error } = z
.string()
.min(5)
.max(64)
- .regex(/^[a-z0-9]*$/, {
+ .regex(/^[a-z0-9\-]*$/, {
message: 'Only lowercase letters and numbers'
})
- .safeParse(debouncedOrgShortcode);
+ .safeParse(orgShortcode);
+ return [
+ success,
+ error ? new Error(error.issues.map((i) => i.message).join(', ')) : null
+ ];
+ }, [orgShortcode]);
- if (!parsed.success) {
- return {
- error: parsed.error.issues[0]?.message ?? null,
- available: false
- };
- }
- return await checkOrgShortcodeApi.fetch(
- { shortcode: debouncedOrgShortcode },
- { signal }
+ const { data, isLoading, error } =
+ platform.org.crud.checkShortcodeAvailability.useQuery(
+ {
+ shortcode: orgShortcode
+ },
+ {
+ enabled: shortcodeValid && customShortcode
+ }
);
- });
-
- const {
- loading: createOrgLoading,
- error: createOrgError,
- run: createOrg
- } = useLoading(async () => {
- if (!orgShortcodeData?.available) return;
- await createOrgApi.mutateAsync({
- orgName,
- orgShortcode: debouncedOrgShortcode
- });
- toast.success('Organization created successfully.');
- router.push(`/join/profile?org=${debouncedOrgShortcode}`);
- });
- useEffect(() => {
- if (customShortcode) return;
- setOrgShortcode(orgName?.toLowerCase().replace(/[^a-z0-9]/g, '') || '');
- }, [orgName, customShortcode]);
+ const { refetch: generateShortcode } =
+ platform.org.crud.generateOrgShortcode.useQuery(
+ {
+ orgName
+ },
+ {
+ enabled: false
+ }
+ );
- useEffect(() => {
- checkOrgShortcode({ clearData: true, clearError: true });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [debouncedOrgShortcode]);
+ const { mutateAsync: createOrg, isPending } =
+ platform.org.crud.createNewOrg.useMutation({
+ onError: (e) => {
+ toast.error('An Error Occurred while creating the Org', {
+ description: e.message
+ });
+ }
+ });
return (
-
{
- if (!open) {
- setOrgName('');
- setOrgShortcode('');
- setCustomShortcode(false);
- }
- }}>
-
-
-
- Create a new Organization
-
-
+
+
{
+ // If the org name is empty, clear the shortcode
+ if (orgName.trim().length === 0 && !customShortcode) {
+ if (orgShortcode) {
+ setOrgShortcode('');
+ }
+ }
+ // If the org name is less than 5 characters, do not generate a shortcode
+ if (customShortcode || orgName.trim().length < 5) return;
+ const { data, error } = await generateShortcode();
-
- Create a new organization
-
-
- Organization Name
- setOrgName(e.target.value)}
- />
-
-
- Organization Short Code
- setOrgName(e.target.value)}
+ leadingSlot={IdentificationCard}
+ />
+
+
+
+ {env.NEXT_PUBLIC_WEBAPP_URL}/
+
{
- setOrgShortcode(e.target.value);
+ setValue={(value) => {
+ setOrgShortcode(value);
setCustomShortcode(true);
}}
/>
-
- {!orgShortcodeData && orgShortcodeDataLoading && (
-
- Checking...
-
- )}
-
- {orgShortcodeData && !orgShortcodeDataLoading && (
-
- {orgShortcodeData.available ? (
-
- ) : (
-
- )}
-
-
- {orgShortcodeData.available
+
+
+
Click to Edit
+
+
+ {orgShortcode.length > 0 && !shortcodeValid
+ ? shortcodeError?.message
+ : isLoading
+ ? 'Checking availability...'
+ : error
+ ? error.message
+ : data
+ ? data.available
? 'Looks good!'
- : orgShortcodeData.error}
-
-
- )}
-
- {orgShortcodeError && !orgShortcodeDataLoading && (
-
- {orgShortcodeError.message}
-
- )}
-
- {createOrgError && !createOrgLoading && (
-
- {createOrgError.message}
-
- )}
-
- createOrg()}>
- Create My Organization
-
-
-
- Cancel
-
-
-
-
-
+ : data?.error ?? 'Shortcode is not available'
+ : ''}
+
+ {
+ if (
+ !orgName ||
+ !shortcodeValid ||
+ (customShortcode && !data?.available) ||
+ isLoading
+ )
+ return;
+ await createOrg({ orgName, orgShortcode });
+ router.push(`/join/profile?org=${orgShortcode}`);
+ }}>
+ Create Organization
+
+
);
}
diff --git a/apps/web/src/app/join/org/_components/join-org.tsx b/apps/web/src/app/join/org/_components/join-org.tsx
index bc720d6a..55c77139 100644
--- a/apps/web/src/app/join/org/_components/join-org.tsx
+++ b/apps/web/src/app/join/org/_components/join-org.tsx
@@ -1,6 +1,5 @@
'use client';
-import useLoading from '@/src/hooks/use-loading';
import { platform } from '@/src/lib/trpc';
import { generateAvatarUrl, getInitials } from '@/src/lib/utils';
import { Button } from '@/src/components/shadcn-ui/button';
@@ -9,6 +8,7 @@ import {
Dialog,
DialogClose,
DialogContent,
+ DialogHeader,
DialogTitle,
DialogTrigger
} from '@/src/components/shadcn-ui/dialog';
@@ -17,12 +17,13 @@ import {
AvatarFallback,
AvatarImage
} from '@/src/components/shadcn-ui/avatar';
-import { Users } from '@phosphor-icons/react';
+import { UsersThree } from '@phosphor-icons/react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useRouter } from 'next/navigation';
+import { DialogDescription } from '@radix-ui/react-dialog';
-export default function JoinOrgButton({
+export function JoinOrg({
hasInviteCode,
inviteCode: initialInviteCode
}: {
@@ -30,51 +31,35 @@ export default function JoinOrgButton({
inviteCode?: string;
}) {
const [inviteCode, setInviteCode] = useState(initialInviteCode ?? '');
- const validateInviteCodeApi =
- platform.useUtils().org.users.invites.validateInvite;
- const joinOrgApi = platform.org.users.invites.redeemInvite.useMutation();
const router = useRouter();
const [modalOpen, setModalOpen] = useState(false);
const {
data: inviteData,
- loading: inviteLoading,
- error: inviteError,
- run: validateInvite,
- clearData: clearInviteData
- } = useLoading(async (signal) => {
- return await validateInviteCodeApi.fetch(
- {
- inviteToken: inviteCode
- },
- { signal }
- );
- });
-
+ isLoading: inviteLoading,
+ error: inviteError
+ } = platform.org.users.invites.validateInvite.useQuery(
+ {
+ inviteToken: inviteCode
+ },
+ {
+ enabled: inviteCode.length === 32
+ }
+ );
const {
- loading: joinLoading,
+ mutateAsync: joinOrg,
error: joinError,
- run: joinOrg
- } = useLoading(async () => {
- const { success, orgShortcode } = await joinOrgApi.mutateAsync({
- inviteToken: inviteCode
- });
- if (success) {
- toast.success(`You have joined ${inviteData?.orgName}!`);
- router.push(`/join/profile?org=${orgShortcode}`);
- setModalOpen(false);
- } else {
- throw new Error('Unknown Error Occurred');
+ isPending: isJoining
+ } = platform.org.users.invites.redeemInvite.useMutation({
+ onSuccess: ({ success }) => {
+ if (success) {
+ toast.success(`You have joined ${inviteData?.orgName}!`);
+ router.push(`/join/profile?org=${inviteData?.orgShortcode}`);
+ setModalOpen(false);
+ }
}
});
- useEffect(() => {
- if (inviteCode && modalOpen) {
- validateInvite();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [modalOpen]);
-
useEffect(() => {
if (hasInviteCode) {
setModalOpen(true);
@@ -99,24 +84,28 @@ export default function JoinOrgButton({
open={modalOpen}>
-
- Join an organization
+ className="flex-1"
+ variant={hasInviteCode ? 'default' : 'ghost'}>
+ Join Existing organization
- Join an Organization
+
+ Join an Organization
+
+ Enter the invite code you received to join an organization
+
+
)}
- {joinError && !joinLoading && (
+ {joinError && !isJoining && (
{joinError.message}
)}
- {
- if (!inviteData) {
- validateInvite({ clearData: true, clearError: true });
- } else {
- joinOrg({ clearData: true, clearError: true });
- }
- }}>
- {inviteData ? 'Join Organization' : 'Validate Invite Code'}
-
-
+
+
+
+ Cancel
+
+
- Cancel
+ loading={inviteLoading || isJoining}
+ disabled={inviteCode.length !== 32}
+ className="flex-1"
+ onClick={async () => {
+ if (!inviteData) return;
+ await joinOrg({ inviteToken: inviteCode });
+ }}>
+ Join Organization
-
+
diff --git a/apps/web/src/app/join/org/page.tsx b/apps/web/src/app/join/org/page.tsx
index 6c4eed9d..718327ae 100644
--- a/apps/web/src/app/join/org/page.tsx
+++ b/apps/web/src/app/join/org/page.tsx
@@ -1,38 +1,57 @@
'use client';
+import { At } from '@phosphor-icons/react';
import Stepper from '../_components/stepper';
-import CreateOrgButton from './_components/create-org';
-import JoinOrgButton from './_components/join-org';
+import { CreateOrg } from './_components/create-org';
+import { JoinOrg } from './_components/join-org';
import { useCookies } from 'next-client-cookies';
+import Image from 'next/image';
-export default function JoinOrg() {
+export default function JoinOrgPage() {
const cookies = useCookies();
const inviteCode = cookies.get('un-invite-code');
- const hasInviteCode = !!inviteCode;
+ const username = cookies.get('un-join-username');
+ const hasInviteCode = Boolean(inviteCode);
return (
-
-
Setup Your Organization
-
+
+
+
+
+
-
- With an organization you can share conversations, notes and email
- identities between members and groups.
-
-
- If you're planning on using UnInbox alone, you'll still need
- an organization to manage all the settings.
-
-
- You can be a member of multiple organizations.
+
+
Set up your organization
+ {/* Username Cookie might not be available if the user is creating a org later, so we conditionally render it */}
+ {username && (
+
+ )}
+
+ With an organization you can share conversations, notes and email
+ identities between members and teams.
+
-
-
+
diff --git a/apps/web/src/app/join/page.tsx b/apps/web/src/app/join/page.tsx
index 19f3bd15..bcd082a7 100644
--- a/apps/web/src/app/join/page.tsx
+++ b/apps/web/src/app/join/page.tsx
@@ -1,156 +1,152 @@
'use client';
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger
-} from '@/src/components/shadcn-ui/tooltip';
import { Button } from '@/src/components/shadcn-ui/button';
import { Checkbox } from '@/src/components/shadcn-ui/checkbox';
import Stepper from './_components/stepper';
-import { Check, Plus, Info } from '@phosphor-icons/react';
+import { At, Check, Plus, SpinnerGap } from '@phosphor-icons/react';
import { useDebounce } from '@uidotdev/usehooks';
import { platform } from '@/src/lib/trpc';
-import { useState, useEffect } from 'react';
-import { toast } from 'sonner';
+import { useEffect, useMemo, useState } from 'react';
import { zodSchemas } from '@u22n/utils/zodSchemas';
-import { useCookies } from 'next-client-cookies';
import { useRouter } from 'next/navigation';
-import Link from 'next/link';
-import useLoading from '@/src/hooks/use-loading';
import { Input } from '@/src/components/shadcn-ui/input';
-import { cn } from '@/src/lib/utils';
import { Label } from '@/src/components/shadcn-ui/label';
+import Image from 'next/image';
+import { useCookies } from 'next-client-cookies';
import { datePlus } from '@u22n/utils/ms';
export default function Page() {
- const [username, setUsername] = useState
();
- const [agree, setAgree] = useState(false);
- const cookies = useCookies();
+ const [username, setUsername] = useState('');
+ const [agree, setAgree] = useState(true);
const router = useRouter();
- const checkUsernameApi =
- platform.useUtils().auth.signup.checkUsernameAvailability;
const debouncedUsername = useDebounce(username, 1000);
+ const cookies = useCookies();
- const {
- loading: usernameLoading,
- data: usernameData,
- error: usernameError,
- run: checkUsername
- } = useLoading(async (signal) => {
- if (!debouncedUsername) return;
- const parsed = zodSchemas.username().safeParse(debouncedUsername);
- if (!parsed.success) {
- return {
- error: parsed.error.issues[0]?.message ?? null,
- available: false
- };
- }
- return await checkUsernameApi.fetch(
- { username: debouncedUsername },
- { signal }
- );
- });
-
- useEffect(() => {
- if (typeof debouncedUsername === 'undefined') return;
- checkUsername({ clearData: true, clearError: true });
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ const [validUsername, usernameError] = useMemo(() => {
+ const { success, error } = zodSchemas
+ .username()
+ .safeParse(debouncedUsername);
+ return [
+ success,
+ error ? new Error(error.issues.map((i) => i.message).join(', ')) : null
+ ];
}, [debouncedUsername]);
- useEffect(() => {
- if (usernameError) {
- toast.error(usernameError.message);
- }
- }, [usernameError]);
+ const { data, isLoading, error } =
+ platform.auth.signup.checkUsernameAvailability.useQuery(
+ {
+ username: debouncedUsername
+ },
+ {
+ enabled: validUsername
+ }
+ );
- function nextStep() {
- if (!username) return;
- cookies.set('un-join-username', username, {
- expires: datePlus('15 minutes')
- });
- router.push('/join/secure');
- }
+ // Load username from cookie if available
+ useEffect(() => {
+ const cookieUsername = cookies.get('un-join-username');
+ if (cookieUsername) setUsername(cookieUsername);
+ }, [cookies]);
return (
-
-
- Choose Your Username
-
-
-
-
- This will be your username across the whole Un ecosystem.
-
-
- It's yours personally and can join as many organizations as you
- want.
+
+
+
+
+
+
+
+ Choose your username
+
+
+
+ This will be your username across the whole Un ecosystem. It's
+ yours personally and can join as many organizations as you want.
-
-
Username
-
+
+
setUsername(e.target.value)}
+ leadingSlot={At}
+ trailingSlot={() => {
+ if (!debouncedUsername) return null;
+ return isLoading ? (
+
+ ) : usernameError ?? error ? (
+
+ ) : data && !isLoading ? (
+ data.available ? (
+
+ ) : (
+
+ )
+ ) : null;
+ }}
+ hint={
+ debouncedUsername
+ ? isLoading
+ ? { message: 'Checking username...' }
+ : usernameError
+ ? {
+ type: 'error',
+ message: usernameError.message
+ }
+ : data && !isLoading
+ ? {
+ type: data.available ? 'success' : 'error',
+ message: data.available
+ ? 'Looks good!'
+ : data.error ?? 'Username is not available'
+ }
+ : error
+ ? {
+ type: 'error',
+ message: error.message
+ }
+ : { message: '' }
+ : { message: '' }
+ }
/>
-
-
-
-
-
- Username can only contain letters, numbers, and underscores.
-
-
- {!usernameData && usernameLoading && (
-
- Checking...
-
- )}
-
- {usernameData && !usernameLoading && (
-
- {usernameData.available ? (
-
- ) : (
-
- )}
-
- {usernameData.available ? 'Looks good!' : usernameData.error}
-
-
- )}
-
- {usernameError && !usernameLoading && (
-
- {usernameError.message}
-
- )}
-
-
-
setAgree(!!e)}
- disabled={!usernameData?.available}
+ id="agree"
/>
-
+
I agree to the UnInbox{' '}
.
-
+
-
-
- I like it!
-
-
- Sign in instead
-
+
{
+ cookies.set('un-join-username', debouncedUsername, {
+ expires: datePlus('15 minutes')
+ });
+ router.push('/join/secure');
+ }}>
+ Claim username
+
+
);
}
diff --git a/apps/web/src/app/join/profile/_components/profile-card.tsx b/apps/web/src/app/join/profile/_components/profile-card.tsx
index 33805ec0..56599e93 100644
--- a/apps/web/src/app/join/profile/_components/profile-card.tsx
+++ b/apps/web/src/app/join/profile/_components/profile-card.tsx
@@ -1,17 +1,16 @@
'use client';
import { Button } from '@/src/components/shadcn-ui/button';
-import { AvatarModal } from '@/src/components/shared/avatar-modal';
import { type RouterOutputs, platform } from '@/src/lib/trpc';
import Stepper from '../../_components/stepper';
-import { useEffect, useState } from 'react';
-import { cn, generateAvatarUrl } from '@/src/lib/utils';
-import useLoading from '@/src/hooks/use-loading';
-import { Camera, Checks, SkipForward } from '@phosphor-icons/react';
+import { useEffect, useMemo, useState } from 'react';
+import { User } from '@phosphor-icons/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'sonner';
-import useAwaitableModal from '@/src/hooks/use-awaitable-modal';
import { Input } from '@/src/components/shadcn-ui/input';
+import Image from 'next/image';
+import { useAvatarUploader } from '@/src/hooks/use-avatar-uploader';
+import { generateAvatarUrl } from '@/src/lib/utils';
type ProfileCardProps = {
orgData: RouterOutputs['account']['profile']['getOrgMemberProfile'];
@@ -19,146 +18,445 @@ type ProfileCardProps = {
};
export function ProfileCard({ orgData, wasInvited }: ProfileCardProps) {
- const [AvatarModalRoot, openAvatarModal] = useAwaitableModal(AvatarModal, {
- publicId: orgData.profile.publicId
- });
- const [avatarUrl, setAvatarUrl] = useState
(null);
- const [firstNameValue, setFirstNameValue] = useState(
- orgData.profile.firstName ?? orgData.profile.handle ?? ''
+ const [name, setName] = useState(
+ `${orgData.profile.firstName ?? orgData.profile.handle ?? ''} ${orgData.profile.lastName ?? ''}`
);
- const [lastNameValue, setLastNameValue] = useState(
- orgData.profile.lastName ?? ''
- );
-
const router = useRouter();
const query = useSearchParams();
const orgShortCode = query.get('org');
+ const { upload, uploadResponse, uploading, error, progress } =
+ useAvatarUploader();
- const {
- error: avatarError,
- loading: avatarLoading,
- run: openModal
- } = useLoading(async () => {
- const avatarTimestamp = new Date(await openAvatarModal({}));
- setAvatarUrl(
- generateAvatarUrl({
- publicId: orgData.profile.publicId,
- avatarTimestamp,
- size: '5xl'
- })
- );
- });
+ const { mutateAsync: updateProfile, isPending } =
+ platform.account.profile.updateOrgMemberProfile.useMutation({
+ onError: (error) => {
+ toast.error("Couldn't update profile", { description: error.message });
+ }
+ });
- const updateProfileApi =
- platform.account.profile.updateOrgMemberProfile.useMutation();
- const {
- loading: saveLoading,
- error: saveError,
- run: saveProfile
- } = useLoading(async () => {
- await updateProfileApi.mutateAsync({
- fName: firstNameValue,
- lName: lastNameValue,
- blurb: orgData.profile.blurb ?? '',
- handle: orgData.profile.handle ?? '',
- profilePublicId: orgData.profile.publicId,
- title: orgData.profile.title ?? ''
+ const avatarUrl = useMemo(() => {
+ if (!orgData.profile.avatarTimestamp && !uploadResponse) {
+ return null;
+ }
+ return generateAvatarUrl({
+ publicId: orgData.profile.publicId,
+ avatarTimestamp:
+ uploadResponse?.avatarTimestamp ?? orgData.profile.avatarTimestamp
});
- });
+ }, [
+ orgData.profile.avatarTimestamp,
+ orgData.profile.publicId,
+ uploadResponse
+ ]);
useEffect(() => {
- if (saveError) {
- toast.error(saveError.message);
+ if (error) {
+ toast.error("Couldn't upload avatar", { description: error.message });
}
- }, [saveError]);
-
- const handleSkip = () => {
- router.push(`/${orgShortCode}`);
- };
- const handleSave = () => {
- saveProfile({ clearData: true, clearError: true });
- router.push(`/${orgShortCode}`);
- };
+ }, [error]);
return (
-
-
- {wasInvited ? 'Got time for a profile?' : 'Edit your profile'}
+
+
+
+
-
-
-
+
+
+ {wasInvited ? 'Got time for a profile?' : 'Complete your profile'}
+
+
+
{wasInvited
? 'This profile has been set by the person who invited you. You can have a separate profile for each organization you join.'
: 'You can have a different profile for each organization you join, lets start with your first one!'}
-
-
Skip this step if you like
+
-
-
{
- openModal({});
- }}>
-
-
- {avatarError && (
-
{avatarError.message}
- )}
-
-
-
- First Name
- setFirstNameValue(e.target.value)}
- />
-
-
- Last Name
- setLastNameValue(e.target.value)}
+
+
+ {avatarUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
-
+ ) : (
+
+ )}
-
+
+
Upload Image
+
+ Min 400x400px, PNG or JPEG
+
- Skip
-
+ size="sm"
+ className="w-fit"
+ variant="outline"
+ loading={uploading}
+ onClick={() => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/png, image/jpeg';
+ input.multiple = false;
+ input.onchange = (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (file) {
+ upload({
+ type: 'orgMember',
+ publicId: orgData.profile.publicId,
+ file
+ });
+ }
+ };
+ input.click();
+ }}>
+ {uploading ? `Uploading ${progress.toFixed(2)}%` : 'Upload'}
+
+
+
+
+
setName(e.target.value)}
+ placeholder="Full Name"
+ leadingSlot={User}
+ />
+
+
- Next
-
+ className="w-full"
+ disabled={!name}
+ loading={isPending}
+ onClick={async () => {
+ await updateProfile({
+ name,
+ profilePublicId: orgData.profile.publicId
+ });
+ router.push(`/${orgShortCode}`);
+ }}>
+ Save Profile
+
+ router.push(`/${orgShortCode}`)}>
+ Skip
-
);
}
+
+const DefaultAvatar = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/join/profile/page.tsx b/apps/web/src/app/join/profile/page.tsx
index 1d9b474f..5f14e279 100644
--- a/apps/web/src/app/join/profile/page.tsx
+++ b/apps/web/src/app/join/profile/page.tsx
@@ -1,10 +1,11 @@
'use client';
-import { Button } from '@/src/components/shadcn-ui/button';
import Link from 'next/link';
+import { Button } from '@/src/components/shadcn-ui/button';
import { ProfileCard } from './_components/profile-card';
import { platform } from '@/src/lib/trpc';
import { useCookies } from 'next-client-cookies';
+import { LoadingSpinner } from '@/src/components/shared/loading-spinner';
export default function Page({
searchParams
@@ -31,24 +32,15 @@ export default function Page({
);
}
- const { data: orgData, isLoading: orgDataLoading } =
- platform.account.profile.getOrgMemberProfile.useQuery({
- orgShortcode: searchParams.org
- });
-
- if (orgDataLoading) {
- return (
-
- );
- }
+ const {
+ data: orgData,
+ error,
+ isLoading
+ } = platform.account.profile.getOrgMemberProfile.useQuery({
+ orgShortcode: searchParams.org
+ });
- if (!orgData) {
+ if (error && !orgData) {
return (
@@ -71,6 +63,10 @@ export default function Page({
}
const wasInvited = Boolean(cookies.get('un-invite-code'));
+ if (isLoading || !orgData) {
+ return
;
+ }
+
return (
) {
- const [state, setState] = useState(null);
-
- const getPasskeyChallenge =
- platform.useUtils().auth.passkey.signUpWithPasskeyStart;
- const verifyPasskeyChallenge =
- platform.auth.passkey.signUpWithPasskeyFinish.useMutation();
-
- const { error, loading, run } = useLoading(async () => {
- setState('Getting a passkey challenge from server');
- const { options, publicId } = await getPasskeyChallenge.fetch({
- username,
- turnstileToken
- });
- setState('Waiting for your passkey to respond');
- const passkeyData = await startRegistration(options).catch((e: Error) => {
- if (e.name === 'NotAllowedError') {
- e.message = 'Passkey verification was timed out or cancelled';
- }
- throw e;
- });
- setState("Verifying your passkey's response");
- await verifyPasskeyChallenge.mutateAsync({
- username,
- publicId,
- registrationResponseRaw: passkeyData
- });
- onResolve(null);
- });
-
- useEffect(() => {
- if (error) {
- setState(error.message);
- }
- }, [error]);
-
- return (
- {
- if (open) onClose();
- }}>
-
- Sign up with Passkey
-
- Press the button below to use your passkey to sign up
-
-
- {state}
-
-
- run()}>
-
- Use your Passkey
-
- onClose()}>
- Cancel
-
-
-
-
- );
-}
-
-export function PasswordModal({
- open,
- onClose,
- onResolve,
- username,
- turnstileToken
-}: ModalComponent<
- { username: string; turnstileToken?: string },
- { recoveryCode: string }
->) {
- const [password, setPassword] = useState();
- const [twoFactorCode, setTwoFactorCode] = useState('');
- const [step, setStep] = useState(1);
-
- return (
- {
- if (open) onClose();
- }}>
-
- Sign up with Password
-
-
- {step === 1 ? 'Choose a password' : 'Setup 2FA'}
-
-
- {step === 1 && (
-
- )}
- {step === 2 && (
-
- )}
-
-
- );
-}
-
-const PasswordModalStep1 = ({
- password,
- setPassword,
- onClose,
- setStep
-}: {
- password?: string;
- setPassword: Dispatch>;
- onClose: () => void;
- setStep: Dispatch>;
-}) => {
- const [error, setError] = useState(null);
- const [confirmPassword, setConfirmPassword] = useState(
- password ?? ''
- );
- const debouncedPassword = useDebounce(password, 1000);
-
- const checkPasswordStrength =
- platform.useUtils().auth.signup.checkPasswordStrength;
-
- const {
- data: passwordCheckData,
- error: passwordCheckError,
- loading: passwordCheckLoading,
- run: checkPassword
- } = useLoading(async (signal) => {
- if (!password) return;
- if (password.length < 8) {
- return { error: 'Password must be at least 8 characters long' };
- }
- return await checkPasswordStrength.fetch({ password }, { signal });
- });
-
- useEffect(() => {
- if (passwordCheckError) {
- setError(passwordCheckError.message);
- }
- }, [passwordCheckError]);
-
- useEffect(() => {
- if (typeof debouncedPassword === 'undefined') return;
- checkPassword({ clearData: true, clearError: true });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [debouncedPassword]);
-
- const passwordValid =
- passwordCheckData &&
- 'score' in passwordCheckData &&
- passwordCheckData.score >= 3 &&
- password === confirmPassword;
-
- return (
-
-
setPassword(e.target.value)}
- autoComplete="new-password"
- id="password"
- placeholder="Password"
- />
- {passwordCheckLoading && (
-
- Checking password strength...
-
- )}
-
- {passwordCheckData && 'error' in passwordCheckData && (
-
- {passwordCheckData.error}
-
- )}
-
- {passwordCheckData && 'score' in passwordCheckData && (
-
- {passwordCheckData.score >= 3 ? (
-
- ) : (
-
- )}
-
= 3 ? 'text-green-10' : 'text-red-10'
- )}>
- Your password is{' '}
- {
- ['very weak', 'weak', 'fair', 'strong', 'very strong'][
- passwordCheckData.score
- ]
- }
-
-
- )}
- setConfirmPassword(e.target.value)}
- id="confirm-password"
- placeholder="Confirm Password"
- />
-
- {error}
-
- setStep(2)}>
- Next
-
- onClose()}>
- Cancel
-
-
- );
-};
-
-const PasswordModalStep2 = ({
- username,
- password,
- twoFactorCode,
- setTwoFactorCode,
- onResolve,
- setStep,
- turnstileToken
-}: {
- username: string;
- password?: string;
- twoFactorCode: string;
- setTwoFactorCode: Dispatch>;
- onResolve: (e: { recoveryCode: string }) => void;
- setStep: Dispatch>;
- turnstileToken?: string;
-}) => {
- const [error, setError] = useState(null);
-
- const twoFaChallenge =
- platform.useUtils().auth.twoFactorAuthentication.createTwoFactorChallenge;
- const signUpWithPassword =
- platform.auth.password.signUpWithPassword2FA.useMutation();
-
- const {
- data: twoFaData,
- loading: twoFaLoading,
- run: generate2Fa
- } = useLoading(async (signal) => {
- return await twoFaChallenge
- .fetch({ username }, { signal })
- .catch((e: Error) => {
- setError(e);
- throw e;
- });
- });
-
- const totpSecret = twoFaData
- ? twoFaData.uri.match(/secret=([^&]+)/)?.[1] ?? ''
- : '';
-
- const { loading: signUpLoading, run: signUp } = useLoading(async () => {
- if (!password || twoFactorCode.length !== 6) return;
- const data = await signUpWithPassword.mutateAsync({
- username,
- password,
- twoFactorCode,
- turnstileToken
- });
-
- if (data.success) {
- onResolve({
- recoveryCode: data.recoveryCode!
- });
- } else {
- setError(new Error(data.error ?? 'An unknown error occurred'));
- }
- });
-
- useEffect(() => {
- generate2Fa();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const inputValid = Boolean(password) && twoFactorCode.length === 6;
-
- return (
-
- {twoFaLoading && (
-
- Generating 2FA challenge
-
- )}
-
- {twoFaData && !twoFaLoading && (
-
-
- Scan this QR code with your 2FA app
-
-
- <>
- {twoFaData && (
-
- )}
- >
-
-
-
-
-
-
-
- Two Factor Code
-
-
-
-
-
-
-
-
-
-
-
-
-
- {error && (
-
- {error.message}
-
- )}
-
- )}
-
-
signUp({ clearData: true, clearError: true })}>
- Finish
-
-
setStep(1)}>
- Back
-
-
- );
-};
-
-export function RecoveryCodeModal({
- open,
- onResolve,
- recoveryCode,
- username
-}: ModalComponent<{ username: string; recoveryCode: string }>) {
- const [downloaded, setDownloaded] = useState(false);
-
- return (
-
-
- Recovery Code
-
-
- Save this recovery code in a safe place, without this code you would
- not be able to recover your account
-
-
-
-
{recoveryCode}
-
{
- setDownloaded(true);
- }}
- />
-
-
-
{
- downloadAsFile(`${username}-recovery-code.txt`, recoveryCode);
- setDownloaded(true);
- }}>
- {!downloaded ? 'Download' : 'Download Again'}
-
-
onResolve(null)}>
- Close
-
-
-
-
- );
-}
diff --git a/apps/web/src/app/join/secure/_components/secure-cards.tsx b/apps/web/src/app/join/secure/_components/secure-cards.tsx
index 4389d3f8..308d3d6d 100644
--- a/apps/web/src/app/join/secure/_components/secure-cards.tsx
+++ b/apps/web/src/app/join/secure/_components/secure-cards.tsx
@@ -1,6 +1,17 @@
-import { Fingerprint, Key } from '@phosphor-icons/react';
-import { Button } from '@/src/components/shadcn-ui/button';
-import { Badge } from '@/src/components/shadcn-ui/badge';
+import { Fingerprint, Lock, Password } from '@phosphor-icons/react';
+import { cn } from '@/src/lib/utils';
+import { type UseFormReturn } from 'react-hook-form';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem
+} from '@/src/components/shadcn-ui/form';
+import { PasswordInput } from '@/src/components/password-input';
+import { platform } from '@/src/lib/trpc';
+import { useDebounce } from '@uidotdev/usehooks';
+import { useEffect } from 'react';
+import { StrengthMeter } from '@/src/components/shared/strength-meter';
type Selected = {
selected: 'passkey' | 'password';
@@ -8,41 +19,181 @@ type Selected = {
};
export function PasskeyCard({ selected, setSelected }: Selected) {
+ const active = selected === 'passkey';
return (
- setSelected('passkey')}
- variant={selected === 'passkey' ? 'secondary' : 'outline'}
- className="min-h-48 flex-1 p-4">
-
-
-
Passkey
-
Fingerprint, Face ID, etc
-
- More Secure and Convenient
-
+ className="h-fit w-full overflow-hidden">
+
+
+
+
+ {!active && (
+ Passkey
+ )}
+
+
+
+ Most Secure
+
+
+ {active && (
+
+
Passkey
+
+ Passkeys are the new replacement for passwords, designed to give
+ you access to an app in an easier and more secure way.
+
+
+ )}
-
+
);
}
-export function PasswordCard({ selected, setSelected }: Selected) {
+export function PasswordCard({
+ selected,
+ setSelected,
+ form
+}: Selected & {
+ form: UseFormReturn<
+ {
+ password: string;
+ confirmPassword: string;
+ validated: boolean;
+ },
+ unknown,
+ undefined
+ >;
+}) {
+ const active = selected === 'password';
+ const password = form.watch('password');
+ const confirmPassword = form.watch('confirmPassword');
+ const debouncedPassword = useDebounce(password, 1000);
+ const passwordMatch =
+ password.length >= 8 && confirmPassword.length >= 8
+ ? password === confirmPassword
+ : null;
+
+ const { data, isLoading } =
+ platform.auth.signup.checkPasswordStrength.useQuery(
+ {
+ password: debouncedPassword
+ },
+ {
+ enabled: active && debouncedPassword.length > 8
+ }
+ );
+
+ useEffect(() => {
+ if (!active) return;
+ form.setValue(
+ 'validated',
+ (password.length >= 8 &&
+ confirmPassword.length >= 8 &&
+ data?.allowed &&
+ password === confirmPassword) ??
+ false
+ );
+ }, [form, active, data?.allowed, password, confirmPassword]);
+
return (
- setSelected('password')}
- variant={selected === 'passkey' ? 'outline' : 'secondary'}
- className="min-h-48 flex-1 p-4">
-
-
-
Password & 2FA
-
Alphanumeric and Rolling Codes
-
- Less Secure and Inconvenient
-
+ className="h-fit w-full overflow-hidden">
+
+
+
+
+
+ {active ? 'Password' : 'Use A Password Instead'}
+
+
+
+ {active && (
+ <>
+
+
+
= 8
+ ? (data?.score ?? -1) + 1
+ : undefined
+ }
+ message={
+ isLoading ? (
+ 'Calculating Strength...'
+ ) : data && passwordMatch !== false ? (
+
+ {
+ ['Super Weak', 'Weak', 'Not Great', 'Great', 'Godlike'][
+ data?.score ?? 0
+ ]
+ }
+
+ ) : passwordMatch === false ? (
+ "Passwords don't match"
+ ) : (
+ ''
+ )
+ }
+ error={data?.allowed ? passwordMatch === false : false}
+ />
+ >
+ )}
-
+
);
}
diff --git a/apps/web/src/app/join/secure/page.tsx b/apps/web/src/app/join/secure/page.tsx
index d665744f..0f5b7d1c 100644
--- a/apps/web/src/app/join/secure/page.tsx
+++ b/apps/web/src/app/join/secure/page.tsx
@@ -1,128 +1,217 @@
'use client';
import { Button } from '@/src/components/shadcn-ui/button';
-import { useCookies } from 'next-client-cookies';
import { useRouter } from 'next/navigation';
import Stepper from '../_components/stepper';
import { PasskeyCard, PasswordCard } from './_components/secure-cards';
-import { useQueryState, parseAsStringLiteral } from 'nuqs';
-import useLoading from '@/src/hooks/use-loading';
import { toast } from 'sonner';
-import {
- PasskeyModal,
- PasswordModal,
- RecoveryCodeModal
-} from './_components/modals';
-import useAwaitableModal from '@/src/hooks/use-awaitable-modal';
import {
TurnstileComponent,
turnstileEnabled
} from '@/src/components/turnstile';
-import { useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
+import Image from 'next/image';
+import { useCookies } from 'next-client-cookies';
+import { At } from '@phosphor-icons/react';
+import { Separator } from '@/src/components/shadcn-ui/separator';
+import { z } from 'zod';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { platform } from '@/src/lib/trpc';
+import { startRegistration } from '@simplewebauthn/browser';
+import { useMutation } from '@tanstack/react-query';
+
+type PasskeyCreationOptions = Parameters[0];
+
+const passwordFormSchema = z.object({
+ password: z.string().min(8),
+ confirmPassword: z.string().min(8),
+ validated: z.boolean()
+});
export default function Page() {
- const cookie = useCookies();
- const username = cookie.get('un-join-username');
- const [selectedAuth, setSelectedAuth] = useQueryState(
- 'auth',
- parseAsStringLiteral(['passkey', 'password']).withDefault('passkey')
+ const cookies = useCookies();
+ const username = cookies.get('un-join-username');
+ const [selectedAuth, setSelectedAuth] = useState<'passkey' | 'password'>(
+ 'passkey'
);
const [turnstileToken, setTurnstileToken] = useState();
const router = useRouter();
+ const form = useForm>({
+ resolver: zodResolver(passwordFormSchema),
+ defaultValues: {
+ password: '',
+ confirmPassword: '',
+ validated: false
+ }
+ });
+ const formValid = form.watch('validated');
+ const {
+ mutateAsync: signUpWithPassword,
+ isPending: signUpWithPasswordPending
+ } = platform.auth.password.signUpWithPassword.useMutation();
+ const { mutateAsync: startPasskey, isPending: passkeyOptionsPending } =
+ platform.auth.passkey.signUpWithPasskeyStart.useMutation();
+ const { mutateAsync: finishPasskey, isPending: signUpWithPasskeyPending } =
+ platform.auth.passkey.signUpWithPasskeyFinish.useMutation();
+ const { mutateAsync: registerPasskey, isPending: registerPasskeyPending } =
+ useMutation({
+ mutationFn: (options: PasskeyCreationOptions) =>
+ startRegistration(options),
+ onError: (error) => {
+ if (error.name === 'NotAllowedError') {
+ toast.error('Passkey verification was timed out or cancelled');
+ }
+ }
+ });
- if (!username) {
- router.push('/join');
- }
+ useEffect(() => {
+ if (!username) router.replace('/join');
+ }, [username, router]);
- const [PasskeyModalRoot, signUpWithPasskey] = useAwaitableModal(
- PasskeyModal,
- {
- username: username ?? '',
- turnstileToken: undefined as string | undefined
+ const createAccount = useCallback(async () => {
+ if (turnstileEnabled && !turnstileToken) {
+ return toast.error('Turnstile token not found');
}
- );
+ if (!username) return;
+ if (selectedAuth === 'passkey') {
+ const { options, publicId } = await startPasskey({
+ turnstileToken,
+ username
+ });
+ const response = await registerPasskey(options);
+ const { success, error } = await finishPasskey({
+ username,
+ publicId,
+ registrationResponseRaw: response
+ })
+ .then(({ success }) => ({
+ success,
+ error: null
+ }))
+ .catch((error) => ({
+ success: false,
+ error: (error as Error).message
+ }));
+ if (!success)
+ return toast.error('Failed to create account', { description: error });
+ } else {
+ if (!formValid) return;
+ const { success, error } = await signUpWithPassword({
+ turnstileToken,
+ username,
+ password: form.getValues('password')
+ })
+ .then(({ success }) => ({
+ success,
+ error: null
+ }))
+ .catch((error) => ({
+ success: false,
+ error: (error as Error).message
+ }));
- const [PasswordModalRoot, signUpWithPassword] = useAwaitableModal(
- PasswordModal,
- {
- username: username ?? '',
- turnstileToken: undefined as string | undefined
+ if (!success)
+ return toast.error('Failed to create account', { description: error });
}
- );
- const [RecoveryCodeModalRoot, showRecoveryCode] = useAwaitableModal(
- RecoveryCodeModal,
- { username: username ?? '', recoveryCode: '' }
- );
-
- const { loading, run: createAccount } = useLoading(
- async () => {
- if (turnstileEnabled && !turnstileToken) {
- toast.error('Turnstile token not found');
- return;
- }
- if (selectedAuth === 'passkey') {
- await signUpWithPasskey({
- username: username ?? '',
- turnstileToken
- });
- } else {
- const { recoveryCode } = await signUpWithPassword({
- username: username ?? '',
- turnstileToken
- });
- await showRecoveryCode({
- recoveryCode
- });
- }
- toast.success(
- 'Your account has been created! Redirecting for Organization Creation'
- );
- router.push('/join/org');
- },
- {
- onError: (error) => {
- if (error) {
- toast.error(error.message);
- }
- }
- }
- );
+ toast.success(
+ 'Your account has been created! Redirecting for Organization Creation'
+ );
+ router.push('/join/org');
+ }, [
+ turnstileToken,
+ username,
+ selectedAuth,
+ router,
+ startPasskey,
+ registerPasskey,
+ finishPasskey,
+ formValid,
+ signUpWithPassword,
+ form
+ ]);
return (
-
-
- Secure your account {username}
+
+
+
+
-
+
+
+
Secure your account
+
+
+
+
+ This will be your username across the whole Un ecosystem. It's
+ yours personally and can join as many organizations as you want.
+
+
+
How do you want to secure your account?
-
+
createAccount()}
- loading={loading}
- disabled={loading || (turnstileEnabled && !turnstileToken)}>
+ onClick={() =>
+ createAccount().catch((err: Error) => {
+ if (err.name === 'NotAllowedError') return;
+ toast.error('Something went wrong', {
+ description: err.message
+ });
+ })
+ }
+ loading={
+ passkeyOptionsPending ||
+ registerPasskeyPending ||
+ signUpWithPasskeyPending ||
+ signUpWithPasswordPending
+ }
+ disabled={
+ (turnstileEnabled && !turnstileToken) ||
+ (selectedAuth === 'password' && !formValid)
+ }>
Create my account
-
-
-
);
}
diff --git a/apps/web/src/components/password-input.tsx b/apps/web/src/components/password-input.tsx
index 52df0096..c01cbbc7 100644
--- a/apps/web/src/components/password-input.tsx
+++ b/apps/web/src/components/password-input.tsx
@@ -8,40 +8,41 @@ import { cn } from '../lib/utils';
export const PasswordInput = forwardRef<
ElementRef<'input'>,
- Omit
+ Omit
>(({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = useState(false);
return (
-
-
- setShowPassword((prev) => !prev)}>
- {showPassword ? (
-
- ) : (
-
- )}
-
- {showPassword ? 'Hide password' : 'Show password'}
-
-
-
+ (
+ setShowPassword((prev) => !prev)}>
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+ {showPassword ? 'Hide password' : 'Show password'}
+
+
+ )}
+ {...props}
+ />
);
});
diff --git a/apps/web/src/components/shadcn-ui/button.tsx b/apps/web/src/components/shadcn-ui/button.tsx
index 66908cd3..08ea155d 100644
--- a/apps/web/src/components/shadcn-ui/button.tsx
+++ b/apps/web/src/components/shadcn-ui/button.tsx
@@ -69,7 +69,7 @@ const Button = React.forwardRef(
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
- disabled={disabled ?? loading}>
+ disabled={loading || disabled}>
{loading ? (
<>
{children}
diff --git a/apps/web/src/components/shadcn-ui/checkbox.tsx b/apps/web/src/components/shadcn-ui/checkbox.tsx
index 2a0acc34..62b3e85d 100644
--- a/apps/web/src/components/shadcn-ui/checkbox.tsx
+++ b/apps/web/src/components/shadcn-ui/checkbox.tsx
@@ -11,13 +11,16 @@ const Checkbox = React.forwardRef<
-
-
+
+
));
diff --git a/apps/web/src/components/shadcn-ui/input.tsx b/apps/web/src/components/shadcn-ui/input.tsx
index 7763d99c..0345dc6d 100644
--- a/apps/web/src/components/shadcn-ui/input.tsx
+++ b/apps/web/src/components/shadcn-ui/input.tsx
@@ -1,21 +1,85 @@
import * as React from 'react';
-
import { cn } from '@/src/lib/utils';
+import type { Icon } from '@phosphor-icons/react';
-export type InputProps = React.InputHTMLAttributes;
+export type InputProps = React.InputHTMLAttributes & {
+ leadingSlot?: Icon | React.FC;
+ trailingSlot?: Icon | React.FC;
+ inputSize?: 'base' | 'lg';
+ hint?: {
+ message: string;
+ type?: 'info' | 'success' | 'error';
+ };
+ fullWidth?: boolean;
+};
const Input = React.forwardRef(
- ({ className, type, ...props }, ref) => {
+ (
+ {
+ className,
+ type,
+ inputSize,
+ hint,
+ fullWidth,
+ leadingSlot: Leading,
+ trailingSlot: Trailing,
+ ...props
+ },
+ ref
+ ) => {
return (
-
+ {Leading && (
+
+
+
+ )}
+ {Trailing && (
+
+
+
)}
- ref={ref}
- {...props}
- />
+
+
+ {hint?.message}
+
+
);
}
);
diff --git a/apps/web/src/components/shared/editable-text.tsx b/apps/web/src/components/shared/editable-text.tsx
new file mode 100644
index 00000000..551d1ec5
--- /dev/null
+++ b/apps/web/src/components/shared/editable-text.tsx
@@ -0,0 +1,40 @@
+import { cn } from '@/src/lib/utils';
+import React, { useRef, useState } from 'react';
+
+type EditableTextProps = {
+ value: string;
+ setValue: (value: string) => void;
+};
+
+export function EditableText({ value, setValue }: EditableTextProps) {
+ const [editingState, setEditingState] = useState(value);
+ const [isEditing, setIsEditing] = useState(false);
+ const inputRef = useRef
(null);
+
+ return isEditing ? (
+ setEditingState(e.target.value)}
+ onBlur={() => {
+ if (editingState.trim() === '' || editingState === value) {
+ setEditingState(value);
+ } else {
+ setValue(editingState);
+ }
+ setIsEditing(false);
+ }}
+ className="w-fit border-none outline-none"
+ />
+ ) : (
+ {
+ setEditingState(value);
+ setIsEditing(true);
+ setTimeout(() => inputRef.current?.focus(), 10);
+ }}
+ className={cn(value && 'decoration-blue-7/70 underline')}>
+ {value || '...'}
+
+ );
+}
diff --git a/apps/web/src/components/shared/loading-spinner.tsx b/apps/web/src/components/shared/loading-spinner.tsx
index 8081c5d5..eb48835d 100644
--- a/apps/web/src/components/shared/loading-spinner.tsx
+++ b/apps/web/src/components/shared/loading-spinner.tsx
@@ -11,7 +11,7 @@ export function LoadingSpinner({
+
+ Strength:
+ {message}
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/env.js b/apps/web/src/env.js
index e6e2f363..de49f197 100644
--- a/apps/web/src/env.js
+++ b/apps/web/src/env.js
@@ -2,6 +2,7 @@ import { z } from 'zod';
import { createEnv } from '@t3-oss/env-core';
const IS_BROWSER = typeof window !== 'undefined';
+const IS_DEV = process.env.NODE_ENV === 'development';
// Don't worry about this block, it is tree-shaken out in the browser
if (!IS_BROWSER) {
@@ -38,13 +39,11 @@ export const env = createEnv({
NEXT_PUBLIC_REALTIME_HOST: z.string(),
NEXT_PUBLIC_REALTIME_PORT: z.coerce.number(),
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(),
- NEXT_PUBLIC_EE_ENABLED: z
- .enum(['true', 'false'])
- .optional()
- .transform((value) => value === 'true')
+ NEXT_PUBLIC_EE_ENABLED: z.enum(['true', 'false'])
},
// process.env is added here to allow access while on server, it is tree-shaken out in the browser
// if you check in the browser, you will see runtimeEnv is set to window.__ENV only
runtimeEnv: IS_BROWSER ? window.__ENV : process.env,
- clientPrefix: 'NEXT_PUBLIC_'
+ clientPrefix: 'NEXT_PUBLIC_',
+ skipValidation: IS_BROWSER && IS_DEV
});
diff --git a/apps/web/src/hooks/use-avatar-uploader.ts b/apps/web/src/hooks/use-avatar-uploader.ts
new file mode 100644
index 00000000..32d866e9
--- /dev/null
+++ b/apps/web/src/hooks/use-avatar-uploader.ts
@@ -0,0 +1,59 @@
+import { uploadTracker } from '@/src/lib/upload';
+import { type TypeId } from '@u22n/utils/typeid';
+import { useCallback, useState } from 'react';
+import { env } from '../env';
+
+export function useAvatarUploader() {
+ const [uploadResponse, setUploadResponse] = useState<{
+ avatarTimestamp: Date;
+ } | null>(null);
+ const [uploading, setUploading] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [error, setError] = useState(null);
+
+ const upload = useCallback(
+ ({
+ type,
+ publicId,
+ file
+ }: {
+ type: 'orgMember' | 'org' | 'contact' | 'team';
+ publicId: TypeId<'org' | 'orgMemberProfile' | 'contacts' | 'teams'>;
+ file: File;
+ }) => {
+ setUploading(true);
+ const formData = new FormData();
+ formData.append('type', type);
+ formData.append('publicId', publicId);
+ formData.append('file', file);
+ setUploadResponse(null);
+ setProgress(0);
+ uploadTracker({
+ formData,
+ method: 'POST',
+ url: `${env.NEXT_PUBLIC_STORAGE_URL}/api/avatar`,
+ onProgress: (progress) => setProgress(progress)
+ })
+ .then((data) => {
+ setUploadResponse(
+ JSON.parse(data as string) as { avatarTimestamp: Date }
+ );
+ setUploading(false);
+ })
+ .catch((e) => {
+ setUploadResponse(null);
+ setError(e as Error);
+ setUploading(false);
+ });
+ },
+ []
+ );
+
+ return {
+ upload,
+ uploadResponse,
+ uploading,
+ progress,
+ error
+ };
+}
diff --git a/apps/web/src/lib/upload.ts b/apps/web/src/lib/upload.ts
index f66e5530..7138fa6e 100644
--- a/apps/web/src/lib/upload.ts
+++ b/apps/web/src/lib/upload.ts
@@ -49,3 +49,5 @@ export default function uploadTracker({
xhr.send(formData);
});
}
+
+export { uploadTracker };
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index de169c01..051e73fe 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -15,7 +15,7 @@ export default async function middleware(req: NextRequest) {
Boolean(publicDynamicRoutes.find((e) => path.startsWith(e)));
// Redirect if already logged in on login page
- if (path === '/') {
+ if (['/', '/join', '/join/secure'].includes(path)) {
if (await isAuthenticated()) {
const redirectData = await getAuthRedirection().catch(() => null);
if (redirectData) {