Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
File renamed without changes.
18 changes: 18 additions & 0 deletions src/app/(protected)/layout.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This layout only protects page rendering. An authenticated but incomplete user can still call /api/trpc directly because protectedProcedure checks only for a session. Note creation/upload, reports, saves, and ratings remain accessible. Add an onboarding-aware tRPC middleware/procedure for protected operations, while leaving the onboarding update and account deletion endpoints exempt.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { auth } from '@src/server/auth';
import { isOnboarded } from '@src/server/onboarding';

export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth.api.getSession({ headers: await headers() });

if (session && !(await isOnboarded(session.user.id))) {
redirect('/get-started');
}

return <>{children}</>;
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
3 changes: 3 additions & 0 deletions src/lib/components/form/FormAutocomplete.tsx
Comment thread
ZeyadAbbas marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ type FormAutocompleteFreeSoloProps = {
label: string;
options: string[];
className?: string;
required?: boolean;
};

export function FormAutocompleteFreeSolo({
label,
options,
className,
required,
...props
}: FormAutocompleteFreeSoloProps) {
const field = useFieldContext<string>();
Expand All @@ -29,6 +31,7 @@ export function FormAutocompleteFreeSolo({
<StyledTextField
{...params}
label={label}
required={required}
error={!field.state.meta.isValid}
helperText={
!field.state.meta.isValid
Expand Down
28 changes: 22 additions & 6 deletions src/lib/schemas/account.ts
Comment thread
ZeyadAbbas marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { z } from 'zod';
import { type SelectUserMetadata } from '@src/server/db/models';
import { studentClassificationEnum } from '@src/server/db/schema/user';

export const UTD_EMAIL_REGEX =
/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9\-]*\.)*utdallas\.edu$/i;

const usernameSchema = z
.string()
.min(3, 'Username must be at least 3 characters')
Expand All @@ -19,15 +23,14 @@ export type EditUsernameSchema = z.infer<typeof editUsernameSchema>;
export const accountSettingsSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
major: z.string().min(1, 'College major is required'),
major: z.string().trim().min(1, 'College major is required'),
minor: z.string().nullable(),
studentClassification: z.enum(studentClassificationEnum.enumValues),
graduationDate: z.date().nullable(),
contactEmail: z
.email({
error: 'Use your UT Dallas email',
pattern:
/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9\-]*\.)*utdallas\.edu$/i,
pattern: UTD_EMAIL_REGEX,
})
.nullable(),
});
Expand All @@ -37,18 +40,31 @@ export type AccountSettingsSchema = z.infer<typeof accountSettingsSchema>;
export const accountOnboardingSchema = z.object({
firstName: z.string().min(1, 'Name is required'),
lastName: z.string().optional(),
major: z.string().optional(),
major: z.string().trim().min(1, 'College major is required'),
minor: z.string().nullable().optional(),
studentClassification: z.enum(studentClassificationEnum.enumValues),
graduationDate: z.date({ error: 'Graduation date is required' }).nullable(),
contactEmail: z
.email({
error: 'Use your UT Dallas email',
pattern:
/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9\-]*\.)*utdallas\.edu$/i,
pattern: UTD_EMAIL_REGEX,
})
.min(1, 'Contact email is required')
.nullable(),
});

export type AccountOnboardingSchema = z.infer<typeof accountOnboardingSchema>;

export function isProfileComplete(
userMetadata:
| Pick<SelectUserMetadata, 'major' | 'contactEmail'>
| null
| undefined,
): boolean {
if (!userMetadata) return false;
return (
userMetadata.major.trim().length > 0 &&
!!userMetadata.contactEmail &&
UTD_EMAIL_REGEX.test(userMetadata.contactEmail)
);
}
11 changes: 8 additions & 3 deletions src/server/api/routers/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { createFileSchema, editFileSchema } from '@src/lib/schemas/note';
import { file as files } from '@src/server/db/schema/file';
import { section as sections } from '@src/server/db/schema/section';
import { callStorageAPI } from '@src/server/storage';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
import {
createTRPCRouter,
onboardedProcedure,
protectedProcedure,
publicProcedure,
} from '../trpc';

const byIdSchema = z.object({
id: z.string().default(''),
Expand Down Expand Up @@ -91,7 +96,7 @@ export const fileRouter = createTRPCRouter({
return files;
}),

create: protectedProcedure
create: onboardedProcedure
.input(createFileSchema)
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.id;
Expand Down Expand Up @@ -158,7 +163,7 @@ export const fileRouter = createTRPCRouter({
});
return newFile.id;
}),
update: protectedProcedure
update: onboardedProcedure
.input(editFileSchema)
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
Expand Down
4 changes: 2 additions & 2 deletions src/server/api/routers/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { TRPCError } from '@trpc/server';
import { eq } from 'drizzle-orm';
import { createReportSchema } from '@src/lib/schemas/moderation';
import { report as reports } from '@src/server/db/schema/reports';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { createTRPCRouter, onboardedProcedure } from '../trpc';

export const reportRouter = createTRPCRouter({
create: protectedProcedure
create: onboardedProcedure
.input(createReportSchema)
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.id;
Expand Down
11 changes: 8 additions & 3 deletions src/server/api/routers/savedNote.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { and, avg, count, eq, isNotNull } from 'drizzle-orm';
import { z } from 'zod';
import { userMetadataToNotes } from '@src/server/db/schema/savedNote';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
import {
createTRPCRouter,
onboardedProcedure,
protectedProcedure,
publicProcedure,
} from '../trpc';

const byFileIdSchema = z.object({
fileId: z.string(),
});

export const savedNoteRouter = createTRPCRouter({
toggle: protectedProcedure
toggle: onboardedProcedure
.input(byFileIdSchema)
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.id;
Expand Down Expand Up @@ -117,7 +122,7 @@ export const savedNoteRouter = createTRPCRouter({
return rows.map((row) => row.file);
}),

rate: protectedProcedure
rate: onboardedProcedure
.input(
z.object({
fileId: z.string(),
Expand Down
13 changes: 13 additions & 0 deletions src/server/api/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import superjson from 'superjson';
import { ZodError } from 'zod';
import { auth } from '@src/server/auth';
import { db } from '@src/server/db';
import { isOnboarded } from '@src/server/onboarding';

/**
* 1. CONTEXT
Expand Down Expand Up @@ -116,6 +117,18 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
*/
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

export const onboardedProcedure = protectedProcedure.use(
async ({ ctx, next }) => {
if (!(await isOnboarded(ctx.session.user.id))) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Finish onboarding before doing this.',
});
}
return next();
},
);

/**
* Admin procedures
* Make sure the user invoking the procedure is an admin
Expand Down
11 changes: 11 additions & 0 deletions src/server/onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { eq } from 'drizzle-orm';
import { isProfileComplete } from '@src/lib/schemas/account';
import { db } from '@src/server/db';
import { userMetadata } from '@src/server/db/schema/user';

export async function isOnboarded(userId: string): Promise<boolean> {
const metadata = await db.query.userMetadata.findFirst({
where: eq(userMetadata.id, userId),
});
return isProfileComplete(metadata);
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const OnboardingFormStep = withForm({
label="Major"
options={majors}
className="grow"
required
/>
)}
</form.AppField>
Expand Down
Loading