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
2 changes: 1 addition & 1 deletion src/api/routers/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export const projectsRouter = router({
envVarKey: row.envVarKey,
name: row.name,
isConfigured: true,
maskedValue: row.value.length <= 4 ? '****' : `****${row.value.slice(-4)}`,
maskedValue: row.value.length <= 12 ? '****' : `****${row.value.slice(-4)}`,
}));
} catch (err) {
// Decryption key missing/wrong — return metadata without value preview
Expand Down
4 changes: 2 additions & 2 deletions src/api/routers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const usersRouter = router({
z.object({
email: z.string().email(),
name: z.string().min(1),
password: z.string().min(1),
password: z.string().min(12),
role: z.enum(['member', 'admin', 'superadmin']).optional(),
}),
)
Expand Down Expand Up @@ -56,7 +56,7 @@ export const usersRouter = router({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
role: z.enum(['member', 'admin', 'superadmin']).optional(),
password: z.string().min(1).optional(),
password: z.string().min(12).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/api/routers/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,45 @@ describe('projectsRouter', () => {
level: 'warning',
});
});

it('masks credential with exactly 11 chars (short — shows ****)', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
// 11 chars: 'abcdefghijk'
mockListProjectCredentials.mockResolvedValue([
{ envVarKey: 'KEY_11', name: null, value: 'abcdefghijk' },
]);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

const result = await caller.credentials.list({ projectId: 'p1' });

expect(result[0].maskedValue).toBe('****');
});

it('masks credential with exactly 12 chars (boundary — shows ****)', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
// 12 chars: 'abcdefghijkl'
mockListProjectCredentials.mockResolvedValue([
{ envVarKey: 'KEY_12', name: null, value: 'abcdefghijkl' },
]);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

const result = await caller.credentials.list({ projectId: 'p1' });

expect(result[0].maskedValue).toBe('****');
});

it('masks credential with exactly 13 chars (long — shows last 4 chars)', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
// 13 chars: 'abcdefghijklm'
mockListProjectCredentials.mockResolvedValue([
{ envVarKey: 'KEY_13', name: null, value: 'abcdefghijklm' },
]);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

const result = await caller.credentials.list({ projectId: 'p1' });

expect(result[0].maskedValue).toBe('****jklm');
});
});

describe('set', () => {
Expand Down
65 changes: 58 additions & 7 deletions tests/unit/api/routers/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ describe('usersRouter', () => {
const result = await caller.create({
email: 'newuser@example.com',
name: 'New User',
password: 'secret123',
password: 'secret123456789',
});

expect(mockBcryptHash).toHaveBeenCalledWith('secret123', 10);
expect(mockBcryptHash).toHaveBeenCalledWith('secret123456789', 10);
expect(mockCreateUser).toHaveBeenCalledWith({
orgId: 'org-1',
email: 'newuser@example.com',
Expand All @@ -152,7 +152,7 @@ describe('usersRouter', () => {
await caller.create({
email: 'newadmin@example.com',
name: 'New Admin',
password: 'secret123',
password: 'secret123456789',
role: 'admin',
});

Expand All @@ -166,7 +166,7 @@ describe('usersRouter', () => {
caller.create({
email: 'superuser@example.com',
name: 'Super User',
password: 'secret123',
password: 'secret123456789',
role: 'superadmin',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
Expand All @@ -181,13 +181,45 @@ describe('usersRouter', () => {
await caller.create({
email: 'super2@example.com',
name: 'Super 2',
password: 'secret123',
password: 'secret123456789',
role: 'superadmin',
});

expect(mockCreateUser).toHaveBeenCalledWith(expect.objectContaining({ role: 'superadmin' }));
});

it('rejects password shorter than 12 characters', async () => {
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await expect(
caller.create({ email: 'x@example.com', name: 'X', password: 'short' }),
).rejects.toThrow();

expect(mockCreateUser).not.toHaveBeenCalled();
});

it('accepts password of exactly 12 characters', async () => {
mockCreateUser.mockResolvedValue({ id: 'new-user-1' });
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await caller.create({ email: 'x@example.com', name: 'X', password: 'exactly12chr' });

expect(mockCreateUser).toHaveBeenCalled();
});

it('accepts password longer than 12 characters', async () => {
mockCreateUser.mockResolvedValue({ id: 'new-user-2' });
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await caller.create({
email: 'x@example.com',
name: 'X',
password: 'this-is-a-very-long-password-123',
});

expect(mockCreateUser).toHaveBeenCalled();
});

it('throws UNAUTHORIZED when not authenticated', async () => {
const caller = createCaller({ user: null, effectiveOrgId: null });
await expect(
Expand Down Expand Up @@ -233,9 +265,9 @@ describe('usersRouter', () => {
mockUpdateUser.mockResolvedValue(undefined);
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await caller.update({ id: 'user-2', password: 'newpassword' });
await caller.update({ id: 'user-2', password: 'newpassword12' });

expect(mockBcryptHash).toHaveBeenCalledWith('newpassword', 10);
expect(mockBcryptHash).toHaveBeenCalledWith('newpassword12', 10);
expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { passwordHash: 'hashed-password' });
});

Expand Down Expand Up @@ -337,6 +369,25 @@ describe('usersRouter', () => {
expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { role: 'admin' });
});

it('rejects update password shorter than 12 characters', async () => {
mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' });
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await expect(caller.update({ id: 'user-2', password: 'tooshort' })).rejects.toThrow();

expect(mockUpdateUser).not.toHaveBeenCalled();
});

it('accepts update password of exactly 12 characters', async () => {
mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' });
mockUpdateUser.mockResolvedValue(undefined);
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await caller.update({ id: 'user-2', password: 'exactly12chr' });

expect(mockUpdateUser).toHaveBeenCalled();
});

it('throws UNAUTHORIZED when not authenticated', async () => {
const caller = createCaller({ user: null, effectiveOrgId: null });
await expect(caller.update({ id: 'user-2', name: 'X' })).rejects.toMatchObject({
Expand Down
11 changes: 9 additions & 2 deletions tools/create-admin-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
*
* Usage (local, with tsx):
* node --env-file=.env --import tsx tools/create-admin-user.ts \
* --email admin@example.com --password changeme --name "Admin"
* --email admin@example.com --password 'MySecurePass1!' --name "Admin"
*
* Inside Docker:
* docker compose exec dashboard node dist/tools/create-admin-user.mjs \
* --email admin@example.com --password changeme --name "Admin"
* --email admin@example.com --password 'MySecurePass1!' --name "Admin"
*
* Note: passwords must be at least 12 characters.
*/

import bcrypt from 'bcrypt';
Expand All @@ -34,6 +36,11 @@ function parseArgs(argv: string[]): { email: string; password: string; name: str
process.exit(1);
}

if (password.length < 12) {
console.error('Error: password must be at least 12 characters.');
process.exit(1);
}

return { email, password, name };
}

Expand Down
5 changes: 4 additions & 1 deletion web/src/components/settings/user-form-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ export function UserFormDialog({ open, onOpenChange, user }: UserFormDialogProps
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isEdit ? 'Enter new password to change' : 'Password'}
placeholder={
isEdit ? 'Enter new password to change (min 12 chars)' : 'Minimum 12 characters'
}
minLength={12}
required={!isEdit}
/>
</div>
Expand Down
Loading