diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 0898810..7f120a0 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,22 +4,17 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
@@ -142,7 +137,7 @@
1774643312599
-
+
@@ -256,7 +251,15 @@
1774712424451
-
+
+
+ 1774714222515
+
+
+
+ 1774714222515
+
+
@@ -279,6 +282,7 @@
-
+
+
\ No newline at end of file
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 5b50764..ae48d32 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -55,7 +55,8 @@ model Asset {
updatedAt DateTime @updatedAt
building Building?
floor Floor?
- organization Organization?
+ organization Organization?
+ user User?
}
model Space {
@@ -77,12 +78,16 @@ model User {
id String @id @default(uuid())
email String @unique
password String
+ name String?
+ avatarUrl String?
+ avatarId String? @unique
role Role @default(EMPLOYEE)
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reservations Reservation[]
organization Organization @relation(fields: [organizationId], references: [id])
+ avatar Asset? @relation(fields: [avatarId], references: [id])
}
model Reservation {
diff --git a/apps/api/src/assets/assets.controller.ts b/apps/api/src/assets/assets.controller.ts
index e261706..a92e6f4 100644
--- a/apps/api/src/assets/assets.controller.ts
+++ b/apps/api/src/assets/assets.controller.ts
@@ -89,6 +89,24 @@ export class AssetsController {
return this.assetsService.uploadFloorPlan(id, file);
}
+ @Post('user/avatar')
+ @UseGuards(JwtAuthGuard)
+ @UseInterceptors(FileInterceptor('file'))
+ async uploadUserAvatar(
+ @Req() req,
+ @UploadedFile(
+ new ParseFilePipe({
+ validators: [
+ new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 5 }), // 5MB
+ new FileTypeValidator({ fileType: 'image/(jpeg|png|webp)' }),
+ ],
+ }),
+ )
+ file: Express.Multer.File,
+ ) {
+ return this.assetsService.uploadUserAvatar(req.user.id, file);
+ }
+
@Get(':id')
async getAsset(@Param('id') id: string, @Res() res: Response) {
try {
diff --git a/apps/api/src/assets/assets.service.ts b/apps/api/src/assets/assets.service.ts
index 2dff373..c91e58b 100644
--- a/apps/api/src/assets/assets.service.ts
+++ b/apps/api/src/assets/assets.service.ts
@@ -65,6 +65,23 @@ export class AssetsService {
},
});
}
+
+ async uploadUserAvatar(id: string, file: Express.Multer.File) {
+ const asset = await this.prisma.asset.create({
+ data: {
+ data: Buffer.from(file.buffer),
+ mimeType: file.mimetype,
+ },
+ });
+
+ return this.prisma.user.update({
+ where: { id },
+ data: {
+ avatarId: asset.id,
+ avatarUrl: `/assets/${asset.id}`,
+ },
+ });
+ }
async getOrganizationLogo(id: string) {
const org = await this.prisma.organization.findUnique({
diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts
index feb0b21..5e1bcc3 100644
--- a/apps/api/src/auth/auth.service.ts
+++ b/apps/api/src/auth/auth.service.ts
@@ -29,6 +29,9 @@ export class AuthService {
user: {
id: user.id,
email: user.email,
+ name: user.name,
+ avatarUrl: user.avatarUrl,
+ avatarId: user.avatarId,
role: user.role,
organizationId: user.organizationId,
},
diff --git a/apps/api/src/reservations/reservations.service.ts b/apps/api/src/reservations/reservations.service.ts
index e8b8aa1..c3f0309 100644
--- a/apps/api/src/reservations/reservations.service.ts
+++ b/apps/api/src/reservations/reservations.service.ts
@@ -74,6 +74,9 @@ export class ReservationsService {
select: {
id: true,
email: true,
+ name: true,
+ avatarUrl: true,
+ avatarId: true,
role: true,
}
}
@@ -236,6 +239,9 @@ export class ReservationsService {
select: {
id: true,
email: true,
+ name: true,
+ avatarUrl: true,
+ avatarId: true,
role: true,
}
},
@@ -282,6 +288,9 @@ export class ReservationsService {
select: {
id: true,
email: true,
+ name: true,
+ avatarUrl: true,
+ avatarId: true,
role: true,
}
}
diff --git a/apps/api/src/users/dto/update-user.dto.ts b/apps/api/src/users/dto/update-user.dto.ts
index 8eb8d08..ee0f4e0 100644
--- a/apps/api/src/users/dto/update-user.dto.ts
+++ b/apps/api/src/users/dto/update-user.dto.ts
@@ -2,6 +2,14 @@ import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validato
import { Role } from '@prisma/client';
export class UpdateUserDto {
+ @IsString()
+ @IsOptional()
+ name?: string;
+
+ @IsString()
+ @IsOptional()
+ avatarUrl?: string;
+
@IsEmail()
@IsOptional()
email?: string;
diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts
index 66d0f87..67ae3f8 100644
--- a/apps/api/src/users/users.controller.ts
+++ b/apps/api/src/users/users.controller.ts
@@ -8,26 +8,39 @@ import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
-@Roles(Role.ADMIN)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
+ @Get('profile')
+ getProfile(@Req() req) {
+ return this.usersService.findOne(req.user.id, req.user.organizationId);
+ }
+
+ @Patch('profile')
+ updateProfile(@Req() req, @Body() updateUserDto: UpdateUserDto) {
+ return this.usersService.updateProfile(req.user.id, req.user.organizationId, updateUserDto);
+ }
+
@Get()
+ @Roles(Role.ADMIN)
findAll(@Req() req) {
return this.usersService.findAll(req.user.organizationId);
}
@Get(':id')
+ @Roles(Role.ADMIN)
findOne(@Param('id') id: string, @Req() req) {
return this.usersService.findOne(id, req.user.organizationId);
}
@Patch(':id')
+ @Roles(Role.ADMIN)
update(@Param('id') id: string, @Req() req, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(id, req.user.organizationId, updateUserDto);
}
@Post('invite')
+ @Roles(Role.ADMIN)
createInvite(@Req() req, @Body('role') role?: string) {
return this.usersService.createInvite(req.user.organizationId, role);
}
diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts
index 9d10247..f52d26f 100644
--- a/apps/api/src/users/users.service.ts
+++ b/apps/api/src/users/users.service.ts
@@ -17,6 +17,9 @@ export class UsersService {
select: {
id: true,
email: true,
+ name: true,
+ avatarUrl: true,
+ avatarId: true,
role: true,
createdAt: true,
updatedAt: true,
@@ -31,6 +34,9 @@ export class UsersService {
select: {
id: true,
email: true,
+ name: true,
+ avatarUrl: true,
+ avatarId: true,
role: true,
createdAt: true,
updatedAt: true,
@@ -58,12 +64,21 @@ export class UsersService {
select: {
id: true,
email: true,
+ name: true,
+ avatarUrl: true,
+ avatarId: true,
role: true,
updatedAt: true,
},
});
}
+ async updateProfile(id: string, organizationId: string, updateUserDto: UpdateUserDto) {
+ // Basic user info update - can't change role via profile update
+ const { role, ...updateData } = updateUserDto;
+ return this.update(id, organizationId, updateData);
+ }
+
async createInvite(organizationId: string, role: string = 'EMPLOYEE') {
const payload = { organizationId, role, type: 'INVITE' };
const token = this.jwtService.sign(payload, { expiresIn: '7d' });
diff --git a/apps/web/package.json b/apps/web/package.json
index 524114b..16ffa87 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -26,6 +26,8 @@
},
"type": "module",
"dependencies": {
+ "@types/crypto-js": "^4.2.2",
+ "crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
diff --git a/apps/web/src/lib/components/Avatar.svelte b/apps/web/src/lib/components/Avatar.svelte
new file mode 100644
index 0000000..4f6a0f9
--- /dev/null
+++ b/apps/web/src/lib/components/Avatar.svelte
@@ -0,0 +1,48 @@
+
+
+
+ {#if user.avatarUrl && !imgError}
+
})
imgError = true}
+ />
+ {/if}
+
+ {#if !user.avatarUrl || imgError}
+

+ {/if}
+
+
+
+ {initials}
+
+
+
diff --git a/apps/web/src/lib/components/FloorplanViewer.svelte b/apps/web/src/lib/components/FloorplanViewer.svelte
index a6c3840..7b38c73 100644
--- a/apps/web/src/lib/components/FloorplanViewer.svelte
+++ b/apps/web/src/lib/components/FloorplanViewer.svelte
@@ -3,6 +3,7 @@
import { io, Socket } from 'socket.io-client';
import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte';
import BookingModal from './BookingModal.svelte';
+ import Avatar from './Avatar.svelte';
import { resolveAssetUrl } from '$lib/utils/url';
export let floorId: string;
@@ -159,14 +160,12 @@
{#if status.isOccupied && space.type === 'DESK'}
-
- {#if status.reservation?.user?.profilePicture}
-
})
- {:else}
-
- {status.reservation?.user?.email?.charAt(0).toUpperCase()}
-
- {/if}
+
{/if}
@@ -177,7 +176,7 @@
{#if status.isOccupied && space.type === 'DESK'}
- {status.reservation?.user?.email.split('@')[0]}
+ {status.reservation?.user?.name || status.reservation?.user?.email.split('@')[0]}
{/if}
diff --git a/apps/web/src/lib/utils/avatar.ts b/apps/web/src/lib/utils/avatar.ts
new file mode 100644
index 0000000..b8e9e91
--- /dev/null
+++ b/apps/web/src/lib/utils/avatar.ts
@@ -0,0 +1,21 @@
+import CryptoJS from 'crypto-js';
+
+export function getGravatarUrl(email: string, size = 100): string {
+ if (!email) return `https://www.gravatar.com/avatar/00000000000000000000000000000000?s=${size}&d=mp`;
+ const hash = CryptoJS.MD5(email.trim().toLowerCase()).toString();
+ return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
+}
+
+export function getInitials(name?: string, email?: string): string {
+ if (name) {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+ }
+ return name.trim().substring(0, 2).toUpperCase();
+ }
+ if (email) {
+ return email.trim().substring(0, 2).toUpperCase();
+ }
+ return '??';
+}
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte
index 1102ac7..bd07f88 100644
--- a/apps/web/src/routes/+layout.svelte
+++ b/apps/web/src/routes/+layout.svelte
@@ -4,6 +4,7 @@
import { organization, loadOrganization } from "$lib/stores/organization";
import { onMount } from "svelte";
import { resolveAssetUrl } from "$lib/utils/url";
+ import Avatar from "$lib/components/Avatar.svelte";
onMount(async () => {
if ($user?.organizationId) {
@@ -37,8 +38,8 @@
{/if}
Dashboard
My Bookings
-
- {$user?.email?.charAt(0).toUpperCase()}
+
+
Profile
{:else}
diff --git a/apps/web/src/routes/dashboard/profile/+page.svelte b/apps/web/src/routes/dashboard/profile/+page.svelte
new file mode 100644
index 0000000..52728b5
--- /dev/null
+++ b/apps/web/src/routes/dashboard/profile/+page.svelte
@@ -0,0 +1,259 @@
+
+
+
+
+
My Profile
+
Manage your account information and preferences.
+
+
+ {#if message}
+
+ {/if}
+
+ {#if error}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
{$user?.name || 'No Name Set'}
+
{$user?.email}
+
+
+
+
+ Role: {$user?.role}
+
+
+
+ ID: {$user?.id}
+
+
+
+
+
+
+
+
+
+
+
+ Personal Information
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index bde03b3..625e5a4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -66,6 +66,8 @@
"apps/web": {
"version": "0.0.1",
"dependencies": {
+ "@types/crypto-js": "^4.2.2",
+ "crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
@@ -3408,6 +3410,11 @@
"@types/node": "*"
}
},
+ "node_modules/@types/crypto-js": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
+ "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="
+ },
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -5615,6 +5622,11 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
+ },
"node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",