Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default function NewDocumentFormBody({
type="text"
label="Document Title"
placeholder="New document title"
maxLength={200}
error={errors.title}
registration={register('title', {
required: 'Title is required',
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/ui/Auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default function LoginForm({
id="email"
label="Email"
placeholder="Enter your email"
maxLength={254}
registration={register('email')}
error={errors.email}
autoComplete="email"
Expand All @@ -59,6 +60,7 @@ export default function LoginForm({
id="password"
label="Password"
placeholder="Enter your password"
maxLength={128}
registration={register('password')}
error={errors.password}
autoComplete="current-password"
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/ui/Auth/register-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default function RegisterForm({
id="email"
label="Email"
placeholder="Enter your email"
maxLength={254}
registration={register('email')}
error={errors.email}
autoComplete="email"
Expand All @@ -56,6 +57,7 @@ export default function RegisterForm({
label="Username"
id="username"
placeholder="Enter your username"
maxLength={50}
registration={register('username')}
error={errors.username}
autoComplete="username"
Expand All @@ -66,6 +68,7 @@ export default function RegisterForm({
label="Full Name"
id="fullname"
placeholder="Enter your full name"
maxLength={100}
registration={register('fullName')}
error={errors.fullName}
autoComplete="name"
Expand All @@ -76,6 +79,7 @@ export default function RegisterForm({
id="password"
label="Password"
placeholder="Enter your password"
maxLength={128}
registration={register('password')}
error={errors.password}
autoComplete="new-password"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function RenameDocumentModal({
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
maxLength={200}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter document title"
onKeyDown={handleKeyDown}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export const CollaboratorsDropdown = ({
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
maxLength={254}
aria-label="Collaborator email"
className="h-8 min-w-0 flex-1 rounded border border-surface-border bg-transparent px-2 text-xs outline-none focus-visible:border-ring"
/>
Expand Down
28 changes: 19 additions & 9 deletions client/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,32 @@ import { useAuth } from '@/context/auth';
import { api } from './api';

/**
* Zod schema validating registration input: valid email, username (min 3
* characters), password (min 8 characters), and optional full name.
* Zod schema validating registration input: valid email (max 254 chars),
* username (3-50 chars, letters/digits/dots/dashes/underscores), password
* (8-128 characters), and optional full name (max 100 chars). Mirrors the
* server-side RegisterUserSchema bounds so users never hit a 400.
*/
export const RegisterSchema = z.object({
email: z.string().email(),
username: z.string().min(3),
password: z.string().min(8),
fullName: z.string().optional(),
email: z.string().email().max(254),
username: z
.string()
.min(3)
.max(50)
.regex(
/^[a-zA-Z0-9_.-]+$/,
'Only letters, digits, dots, dashes and underscores allowed',
),
password: z.string().min(8).max(128),
fullName: z.string().max(100).optional(),
});

/**
* zod schema validating login credentials (email format + required password).
* Zod schema validating login credentials: valid email (max 254 chars) and a
* password within the server-accepted length bounds.
*/
export const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
email: z.string().email().max(254),
password: z.string().min(8).max(128),
});

export type RegisterSchemaType = z.infer<typeof RegisterSchema>;
Expand Down
18 changes: 3 additions & 15 deletions server/src/controllers/document.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,8 @@ export const getShareLink = asyncErrorWrapper(async (req: AuthenticatedRequest,
const clientInfo = getClientInfo(req);
const userId = req.user?.userId;
const { id } = req.params;
const { permission = 'view' } = req.query;
// Validated + defaulted by ShareLinkQuerySchema on the route
const { permission } = req.query as { permission: 'view' | 'edit' };

logger.debug('Share link generation attempt', {
action: 'GENERATE_SHARE_LINK_ATTEMPT',
Expand All @@ -554,19 +555,6 @@ export const getShareLink = asyncErrorWrapper(async (req: AuthenticatedRequest,
permission,
});

if (!['view', 'edit'].includes(permission as string)) {
logger.warn('Share link generation failed - invalid permission', {
action: 'GENERATE_SHARE_LINK_INVALID_PERMISSION',
...clientInfo,
userId,
documentId: id,
permission,
});

res.status(StatusCodes.BAD_REQUEST).json({ error: 'Invalid permission' });
return;
}

try {
const doc = await prisma.document.findUnique({ where: { id } });

Expand All @@ -584,7 +572,7 @@ export const getShareLink = asyncErrorWrapper(async (req: AuthenticatedRequest,
return;
}

const token = generateShareToken(doc.shareId, permission as 'view' | 'edit');
const token = generateShareToken(doc.shareId, permission);
// Updated URL structure - token is now in the path
const url = `${process.env.CLIENT_BASE}/app/doc/share/${token}`;

Expand Down
49 changes: 33 additions & 16 deletions server/src/routers/document.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,43 @@ import {
import { authenticate } from '@/middlewares/auth.middleware';
import { validate } from '@/middlewares/validation.middleware';
import { AddCollaboratorSchema } from '@/validations/addCollaborator.schema';
import { CreateDocumentSchema } from '@/validations/createDocument.schema';
import {
CollaboratorParamsSchema,
IdParamsSchema,
RequestIdParamsSchema,
ShareLinkQuerySchema,
} from '@/validations/documentParams.schema';
import { UpdateDocSettingsSchema } from '@/validations/updateDocSettings.schema';
import { UpdateDocumentSchema } from '@/validations/updateDocument.schema';

export const docRouter = express.Router();

docRouter.use(authenticate);
docRouter.get('/share/:token', getDocByToken);

docRouter.post('/', createDoc);
docRouter.post('/', validate({ body: CreateDocumentSchema }), createDoc);
docRouter.get('/', getDocs);
docRouter.get('/:id', getDoc);
docRouter.put('/:id', updateDoc);
docRouter.delete('/:id', deleteDoc);

docRouter.patch('/:id/settings', updateDocSettings); // Used to toggle allowSelfJoin for the document // !Owner only access

docRouter.get('/:id/share-link', getShareLink); // get the document share link with the share token

docRouter.get('/:id/collaborators', getCollaborators); // returns list
docRouter.post('/:id/collaborators', validate({ body: AddCollaboratorSchema }), addCollaborator); // adds a new one by email //!Owner only access
docRouter.delete('/:id/collaborators/:userId', removeCollaborator); // optional

docRouter.get('/:id/requests', getRequests); // !Owner only access
docRouter.post('/:id/requests/:requestId/approve', approveRequest);
docRouter.delete('/:id/requests/:requestId/reject', rejectRequest);
docRouter.get('/:id', validate({ params: IdParamsSchema }), getDoc);
docRouter.put('/:id', validate({ params: IdParamsSchema, body: UpdateDocumentSchema }), updateDoc);
docRouter.delete('/:id', validate({ params: IdParamsSchema }), deleteDoc);

docRouter.patch(
'/:id/settings',
validate({ params: IdParamsSchema, body: UpdateDocSettingsSchema }),
updateDocSettings
); // Used to toggle allowSelfJoin for the document // !Owner only access

docRouter.get('/:id/share-link', validate({ params: IdParamsSchema, query: ShareLinkQuerySchema }), getShareLink); // get the document share link with the share token

docRouter.get('/:id/collaborators', validate({ params: IdParamsSchema }), getCollaborators); // returns list
docRouter.post(
'/:id/collaborators',
validate({ params: IdParamsSchema, body: AddCollaboratorSchema }),
addCollaborator
); // adds a new one by email //!Owner only access
docRouter.delete('/:id/collaborators/:userId', validate({ params: CollaboratorParamsSchema }), removeCollaborator); // optional

docRouter.get('/:id/requests', validate({ params: IdParamsSchema }), getRequests); // !Owner only access
docRouter.post('/:id/requests/:requestId/approve', validate({ params: RequestIdParamsSchema }), approveRequest);
docRouter.delete('/:id/requests/:requestId/reject', validate({ params: RequestIdParamsSchema }), rejectRequest);
1 change: 1 addition & 0 deletions server/src/validations/addCollaborator.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const AddCollaboratorSchema = z.object({
email: z
.string()
.email()
.max(254)
.transform(email => email.trim().toLowerCase()),
permission: z.enum(['edit', 'view']).default('edit'),
});
Expand Down
9 changes: 9 additions & 0 deletions server/src/validations/createDocument.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from 'zod';

export const CreateDocumentSchema = z.object({
title: z.string().trim().min(1).max(200),
content: z.string().max(1_000_000).optional(),
isPublic: z.boolean().default(false),
});

export type CreateDocumentSchema = z.infer<typeof CreateDocumentSchema>;
27 changes: 27 additions & 0 deletions server/src/validations/documentParams.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from 'zod';

export const IdParamsSchema = z.object({
id: z.string().uuid(),
});

export type IdParamsSchema = z.infer<typeof IdParamsSchema>;

export const RequestIdParamsSchema = z.object({
id: z.string().uuid(),
requestId: z.string().uuid(),
});

export type RequestIdParamsSchema = z.infer<typeof RequestIdParamsSchema>;

export const CollaboratorParamsSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
});

export type CollaboratorParamsSchema = z.infer<typeof CollaboratorParamsSchema>;

export const ShareLinkQuerySchema = z.object({
permission: z.enum(['view', 'edit']).default('view'),
});

export type ShareLinkQuerySchema = z.infer<typeof ShareLinkQuerySchema>;
3 changes: 2 additions & 1 deletion server/src/validations/login.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ export const LoginUserSchema = z.object({
email: z
.string()
.email()
.max(254)
.transform(email => email.trim().toLowerCase()),
password: z.string().min(6),
password: z.string().min(8).max(128),
});

export type LoginUserSchema = z.infer<typeof LoginUserSchema>;
11 changes: 8 additions & 3 deletions server/src/validations/register.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ export const RegisterUserSchema = z.object({
email: z
.string()
.email()
.max(254)
.transform(email => email.trim().toLowerCase()),
username: z.string().min(3),
password: z.string().min(6),
fullName: z.string().optional(),
username: z
.string()
.min(3)
.max(50)
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dots, dashes and underscores'),
password: z.string().min(8).max(128),
fullName: z.string().max(100).optional(),
});

export type RegisterUserSchema = z.infer<typeof RegisterUserSchema>;
7 changes: 7 additions & 0 deletions server/src/validations/updateDocSettings.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod';

export const UpdateDocSettingsSchema = z.object({
allowSelfJoin: z.boolean(),
});

export type UpdateDocSettingsSchema = z.infer<typeof UpdateDocSettingsSchema>;
9 changes: 9 additions & 0 deletions server/src/validations/updateDocument.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from 'zod';

export const UpdateDocumentSchema = z.object({
title: z.string().trim().min(1).max(200).optional(),
content: z.string().max(1_000_000).optional(),
isPublic: z.boolean().optional(),
});

export type UpdateDocumentSchema = z.infer<typeof UpdateDocumentSchema>;
20 changes: 20 additions & 0 deletions server/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ describe('Auth Routes', () => {
expect(res.status).toBe(StatusCodes.BAD_REQUEST);
});

it('should reject registration with a 6-character password', async () => {
const res = await request(app).post('/api/auth/register').send({
email: 'shortpass@test.dev',
username: 'shortpass',
password: 'abcdef',
});

expect(res.status).toBe(StatusCodes.BAD_REQUEST);
});

it('should reject registration with a username containing spaces', async () => {
const res = await request(app).post('/api/auth/register').send({
email: 'spaceuser@test.dev',
username: 'bad name',
password: 'secure123',
});

expect(res.status).toBe(StatusCodes.BAD_REQUEST);
});

it('should reject registration with missing fields', async () => {
const res = await request(app).post('/api/auth/register').send({
email: 'newuser@test.dev',
Expand Down
Loading
Loading