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 .github/workflows/CI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ jobs:
- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_API_URL: http://localhost:6003/api

- name: Unit tests
run: npm run test -- --passWithNoTests
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,23 @@ The backend will be available at `http://localhost:3001`.

```bash
cd frontend
cp .env.example .env.local # optional: defaults work for local development
npm install
npm run dev
```

The frontend will be available at `http://localhost:3000`.

#### Frontend environment variables

Validated at startup by `frontend/lib/env.ts`; a missing optional value falls back to the default, an invalid one fails fast with a readable message.

| Variable | Default | Description |
| --- | --- | --- |
| `NEXT_PUBLIC_API_URL` | `http://localhost:6003/api` | Backend REST API base URL, including `/api` |
| `NEXT_PUBLIC_WS_URL` | derived from `NEXT_PUBLIC_API_URL` | Socket.IO endpoint for real-time notifications |
| `NEXT_PUBLIC_STELLAR_NETWORK` | `testnet` | Network the Freighter wallet must use (`testnet` or `mainnet`) |

---

## Usage
Expand Down
2 changes: 2 additions & 0 deletions backend/src/assets/dto/list-assets-query.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const ASSET_SORT_COLUMNS = [
'assetTag',
'status',
'purchaseDate',
'purchaseCost',
'warrantyExpiry',
] as const;

export class ListAssetsQueryDto extends PaginationQueryDto {
Expand Down
44 changes: 44 additions & 0 deletions backend/src/users/dto/invite-user.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsString,
MaxLength,
} from 'class-validator';
import { UserRole } from '../entities/user.entity';

// The frontend sends lowercase role names ("staff"); accept those as well as
// the canonical enum values.
const ROLE_ALIASES: Record<string, UserRole> = {
admin: UserRole.ADMIN,
manager: UserRole.MANAGER,
staff: UserRole.EMPLOYEE,
employee: UserRole.EMPLOYEE,
};

export class InviteUserDto {
@ApiProperty({ example: 'new.hire@example.com' })
@IsEmail()
email: string;

@ApiProperty({ example: 'Jane' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName: string;

@ApiProperty({ example: 'Doe' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName: string;

@ApiProperty({ enum: UserRole })
@Transform(({ value }) =>
typeof value === 'string' ? (ROLE_ALIASES[value.toLowerCase()] ?? value) : value,
)
@IsEnum(UserRole)
role: UserRole;
}
30 changes: 30 additions & 0 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Controller,
Get,
Patch,
Post,
Param,
Body,
Query,
Expand All @@ -24,6 +25,7 @@ import { GetUser } from '../auth/decorators/get-user.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { InviteUserDto } from './dto/invite-user.dto';

@ApiTags('users')
@ApiBearerAuth('JWT-auth')
Expand Down Expand Up @@ -76,4 +78,32 @@ export class UsersController {
const requestingUserId = req.user?.id || req.user?.sub;
return this.usersService.updateRole(id, role, requestingUserId);
}

@Post('invite')
@UseGuards(RolesGuard)
@Roles('ADMIN')
@ApiOperation({ summary: 'Invite a new user (admin only)' })
@ApiResponse({ status: 201, description: 'User invited' })
@ApiResponse({ status: 403, description: 'Not an admin' })
@ApiResponse({ status: 409, description: 'Email already registered' })
invite(@Body() dto: InviteUserDto, @Req() req: any) {
const requestingUserId = req.user?.id || req.user?.sub;
return this.usersService.invite(dto, requestingUserId);
}

@Patch(':id/active')
@UseGuards(RolesGuard)
@Roles('ADMIN')
@ApiOperation({ summary: 'Activate or deactivate a user (admin only)' })
@ApiResponse({ status: 200, description: 'User updated' })
@ApiResponse({ status: 403, description: 'Not an admin, or self-deactivation' })
@ApiResponse({ status: 404, description: 'User not found' })
setActive(
@Param('id') id: string,
@Body('isActive') isActive: boolean,
@Req() req: any,
) {
const requestingUserId = req.user?.id || req.user?.sub;
return this.usersService.setActive(id, isActive === true, requestingUserId);
}
}
42 changes: 41 additions & 1 deletion backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { User, UserRole } from './entities/user.entity';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { AuditLogsService } from '../audit-logs/audit-logs.service';
import { normalizeEmail } from './utils/normalize-email';
import * as crypto from 'crypto';

@Injectable()
export class UsersService {
Expand Down Expand Up @@ -140,7 +141,46 @@ export class UsersService {
return this.sanitize(saved);
}

async setActive(id: string, isActive: boolean) {
/**
* Creates an account for an invited person. They get an unusable random
* password and a 7-day password-set token. The raw token is only returned
* when EXPOSE_RESET_TOKEN=true (local development), exactly like the
* forgot-password flow, until email delivery is wired up.
*/
async invite(
dto: { email: string; firstName: string; lastName: string; role?: UserRole },
invitedById?: string,
) {
const created = await this.create({
...dto,
password: crypto.randomBytes(24).toString('base64url'),
});
const inviteToken = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto
.createHash('sha256')
.update(inviteToken)
.digest('hex');
await this.setPasswordResetToken(
created.id,
tokenHash,
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
);
await this.auditLogsService?.logAction({
action: 'INVITED',
entityType: 'user',
entityId: created.id,
actorId: invitedById,
newValue: { email: created.email, role: created.role },
});
return process.env.EXPOSE_RESET_TOKEN === 'true'
? { ...created, inviteToken }
: created;
}

async setActive(id: string, isActive: boolean, requestingUserId?: string) {
if (requestingUserId && requestingUserId === id && !isActive) {
throw new ForbiddenException('Cannot deactivate your own account');
}
const user = await this.findById(id);
user.isActive = isActive;
const saved = await this.userRepository.save(user);
Expand Down
78 changes: 78 additions & 0 deletions frontend/__tests__/department-drawer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { DepartmentDrawer } from "@/components/departments/department-drawer";
import {
useDepartmentAssets,
useDepartmentUsers,
} from "@/lib/query/hooks/useDepartments";

jest.mock("@/lib/query/hooks/useDepartments", () => ({
useDepartmentUsers: jest.fn(),
useDepartmentAssets: jest.fn(),
}));
jest.mock("@/components/ui/sheet", () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}));
jest.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TabsTrigger: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
TabsContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

const department = {
id: "dept-1",
name: "Engineering",
memberCount: 0,
assetCount: 0,
createdAt: "2024-01-01",
};

const ok = (items: unknown[], total = items.length) => ({
data: { items, total, page: 1, limit: 10, totalPages: Math.max(1, Math.ceil(total / 10)) },
isLoading: false,
isError: false,
refetch: jest.fn(),
});

describe("DepartmentDrawer", () => {
beforeEach(() => jest.clearAllMocks());

it("shows distinct empty states", () => {
(useDepartmentUsers as jest.Mock).mockReturnValue(ok([]));
(useDepartmentAssets as jest.Mock).mockReturnValue(ok([]));
render(<DepartmentDrawer isOpen onClose={() => {}} department={department} />);
expect(screen.getByText("No members in this department")).toBeInTheDocument();
expect(screen.getByText("No assets in this department")).toBeInTheDocument();
});

it("renders rows that link to the asset page", () => {
(useDepartmentUsers as jest.Mock).mockReturnValue(
ok([{ id: "u1", firstName: "Ada", lastName: "Lovelace", email: "ada@x.io", role: "ADMIN" }]),
);
(useDepartmentAssets as jest.Mock).mockReturnValue(
ok([{ id: "a1", name: "Laptop", assetTag: "AST-1", status: "ACTIVE" }]),
);
render(<DepartmentDrawer isOpen onClose={() => {}} department={department} />);
expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();
expect(screen.getByText("Laptop").closest("a")).toHaveAttribute("href", "/assets/a1");
expect(screen.getByText("Members (1)")).toBeInTheDocument();
});

it("shows an error state with a retry button", () => {
const refetch = jest.fn();
(useDepartmentUsers as jest.Mock).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
refetch,
});
(useDepartmentAssets as jest.Mock).mockReturnValue(ok([]));
render(<DepartmentDrawer isOpen onClose={() => {}} department={department} />);
expect(screen.getByText("Could not load members.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(refetch).toHaveBeenCalled();
});
});
36 changes: 36 additions & 0 deletions frontend/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { parseEnv } from "@/lib/env";

describe("parseEnv", () => {
it("applies local defaults when nothing is set", () => {
const env = parseEnv({});
expect(env.NEXT_PUBLIC_API_URL).toBe("http://localhost:6003/api");
expect(env.NEXT_PUBLIC_STELLAR_NETWORK).toBe("testnet");
expect(env.NEXT_PUBLIC_WS_URL).toBeUndefined();
});

it("treats empty strings as unset", () => {
expect(parseEnv({ NEXT_PUBLIC_API_URL: "" }).NEXT_PUBLIC_API_URL).toBe(
"http://localhost:6003/api",
);
});

it("accepts explicit values", () => {
const env = parseEnv({
NEXT_PUBLIC_API_URL: "https://api.example.com/api",
NEXT_PUBLIC_WS_URL: "wss://api.example.com",
NEXT_PUBLIC_STELLAR_NETWORK: "mainnet",
});
expect(env.NEXT_PUBLIC_API_URL).toBe("https://api.example.com/api");
expect(env.NEXT_PUBLIC_WS_URL).toBe("wss://api.example.com");
expect(env.NEXT_PUBLIC_STELLAR_NETWORK).toBe("mainnet");
});

it("fails fast with a readable message on invalid values", () => {
expect(() => parseEnv({ NEXT_PUBLIC_API_URL: "not a url" })).toThrow(
/NEXT_PUBLIC_API_URL/,
);
expect(() => parseEnv({ NEXT_PUBLIC_STELLAR_NETWORK: "devnet" })).toThrow(
/NEXT_PUBLIC_STELLAR_NETWORK/,
);
});
});
38 changes: 38 additions & 0 deletions frontend/__tests__/user-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getUserActionErrorMessage } from "@/lib/api/users";

const withStatus = (status: number, message?: string) => ({
response: { status, data: message ? { message } : {} },
});

describe("getUserActionErrorMessage", () => {
it("explains a duplicate invite (409)", () => {
expect(getUserActionErrorMessage(withStatus(409), "invite")).toMatch(
/already been invited/i,
);
});

it("explains a forbidden action (403)", () => {
expect(getUserActionErrorMessage(withStatus(403))).toMatch(/administrators/i);
});

it("prefers the server message for 403 and 400", () => {
expect(
getUserActionErrorMessage(withStatus(403, "Cannot change your own role")),
).toBe("Cannot change your own role");
expect(getUserActionErrorMessage(withStatus(400, "email must be an email"))).toBe(
"email must be an email",
);
});

it("explains not found (404)", () => {
expect(getUserActionErrorMessage(withStatus(404))).toMatch(/not found/i);
});

it("explains server errors (5xx)", () => {
expect(getUserActionErrorMessage(withStatus(500))).toMatch(/server/i);
});

it("falls back to a generic message", () => {
expect(getUserActionErrorMessage(new Error("boom"))).toMatch(/something went wrong/i);
});
});
Loading
Loading