Skip to content

Commit 94d8d67

Browse files
committed
improve profile view
1 parent 9b561b0 commit 94d8d67

3 files changed

Lines changed: 171 additions & 11 deletions

File tree

client/src/components/profile/index.vue

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,45 @@
33
<v-row>
44
<v-col cols="12" md="6" lg="4">
55
<v-card class="pa-4">
6-
<v-avatar size="80" class="mb-4">
7-
<v-img :src="user.image || defaultAvatar" alt="User avatar" />
8-
</v-avatar>
6+
<div style="position: relative; display: inline-block;">
7+
<v-avatar size="150" class="mb-4">
8+
<v-img :src="user.image || defaultAvatar" alt="User avatar" />
9+
</v-avatar>
10+
<v-btn
11+
icon
12+
size="x-small"
13+
color="secondary"
14+
style="position: absolute; bottom: 8px; right: 8px; z-index: 2;"
15+
@click="editAvatarDialog = true"
16+
>
17+
<v-icon>mdi-pencil</v-icon>
18+
</v-btn>
19+
</div>
920
<h2 class="mb-1">{{ user.firstName }} {{ user.lastName }}</h2>
10-
<div class="text--secondary mb-2">@{{ user.username }}</div>
21+
<div class="text-h5 font-weight-bold mb-2">{{ user.username }}</div>
1122
<div class="mb-2">{{ user.email }}</div>
12-
<v-chip v-if="user.role" color="primary" class="mb-2">{{ user.role.name }}</v-chip>
1323
<div class="text--secondary">Last login: <span v-if="user.lastLogin">{{ new Date(user.lastLogin).toLocaleString() }}</span><span v-else>-</span></div>
24+
<v-dialog v-model="editAvatarDialog" max-width="400px">
25+
<v-card>
26+
<v-card-title>Edit Avatar</v-card-title>
27+
<v-card-text>
28+
<v-alert type="warning" density="compact" class="mb-2">
29+
The image must not exceed 100KB.
30+
</v-alert>
31+
<v-file-input
32+
v-model="avatarFile"
33+
label="Upload new avatar"
34+
accept="image/*"
35+
prepend-icon="mdi-image"
36+
></v-file-input>
37+
</v-card-text>
38+
<v-card-actions>
39+
<v-spacer />
40+
<v-btn text @click="editAvatarDialog = false">Cancel</v-btn>
41+
<v-btn color="primary" @click="saveAvatar">Save</v-btn>
42+
</v-card-actions>
43+
</v-card>
44+
</v-dialog>
1445
</v-card>
1546
</v-col>
1647
<v-col cols="12" md="6" lg="8">
@@ -35,14 +66,22 @@
3566
</v-list-item>
3667
<v-list-item>
3768
<v-list-item-title>Role</v-list-item-title>
38-
<v-list-item-subtitle>{{ user.role ? user.role.name : '-' }}</v-list-item-subtitle>
69+
70+
<v-chip
71+
class="ma-2"
72+
color="primary"
73+
label
74+
v-if="user.role"
75+
>
76+
<v-icon icon="mdi-account-circle-outline" start></v-icon>
77+
{{ user.role.name}}
78+
</v-chip>
79+
<span v-else>-</span>
3980
</v-list-item>
4081
<v-list-item>
4182
<v-list-item-title>Groups</v-list-item-title>
42-
<v-list-item-subtitle>
43-
<v-chip v-for="group in user.userGroups" :key="group.id" class="ma-1" color="secondary">{{ group.name }}</v-chip>
83+
<v-chip v-for="group in user.userGroups" :key="group.id" class="ma-1" color="grey">{{ group.name }}</v-chip>
4484
<span v-if="!user.userGroups || user.userGroups.length === 0">-</span>
45-
</v-list-item-subtitle>
4685
</v-list-item>
4786
<v-list-item>
4887
<v-list-item-title>Provider</v-list-item-title>
@@ -113,6 +152,8 @@ export default defineComponent({
113152
})
114153
const defaultAvatar = '/avatar.svg'
115154
const tokens = ref<any[]>([])
155+
const editAvatarDialog = ref(false)
156+
const avatarFile = ref<File | null>(null)
116157
117158
const loadProfile = async () => {
118159
try {
@@ -141,6 +182,21 @@ export default defineComponent({
141182
}
142183
}
143184
185+
const saveAvatar = async () => {
186+
if (!avatarFile.value) return
187+
const formData = new FormData()
188+
formData.append('avatar', avatarFile.value)
189+
try {
190+
await axios.post('/api/users/profile/avatar', formData, {
191+
headers: { 'Content-Type': 'multipart/form-data' },
192+
})
193+
editAvatarDialog.value = false
194+
await loadProfile()
195+
} catch (e) {
196+
// error handling
197+
}
198+
}
199+
144200
onMounted(() => {
145201
loadProfile()
146202
loadTokens()
@@ -151,6 +207,9 @@ export default defineComponent({
151207
defaultAvatar,
152208
tokens,
153209
deleteToken,
210+
editAvatarDialog,
211+
avatarFile,
212+
saveAvatar,
154213
}
155214
},
156215
})

server/src/users/users.controller.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
Put,
1111
Request,
1212
UseGuards,
13+
UploadedFile,
14+
UseInterceptors,
1315

1416
} from '@nestjs/common';
1517
import {
@@ -22,6 +24,8 @@ import { JwtAuthGuard } from '../auth/strategies/jwt.guard';
2224
import { OKDTO } from '../common/dto/ok.dto';
2325
import { User, UsersService } from './users.service';
2426
import { GetAllUsersDTO } from './dto/users.dto';
27+
import { FileInterceptor } from '@nestjs/platform-express';
28+
import { Express } from 'express';
2529

2630
@Controller({ path: 'api/users', version: '1' })
2731
export class UsersController {
@@ -245,4 +249,33 @@ export class UsersController {
245249
const user = req.user;
246250
return this.usersService.findById(user.userId);
247251
}
252+
253+
@Post('/profile/avatar')
254+
@UseGuards(JwtAuthGuard)
255+
@UseInterceptors(FileInterceptor('avatar'))
256+
@ApiBearerAuth('bearerAuth')
257+
@ApiForbiddenResponse({
258+
description: 'Error: Unauthorized',
259+
type: OKDTO,
260+
isArray: false,
261+
})
262+
@ApiOkResponse({
263+
description: 'Update current User avatar',
264+
type: GetAllUsersDTO,
265+
isArray: false,
266+
})
267+
@ApiOperation({ summary: 'Update current User avatar' })
268+
async updateProfileAvatar(
269+
@Request() req: any,
270+
@UploadedFile() file: any,
271+
) {
272+
const user = req.user;
273+
if (!file) {
274+
throw new HttpException('No avatar file uploaded', HttpStatus.BAD_REQUEST);
275+
}
276+
if (file.size > 102400) { // 100KB
277+
throw new HttpException('Avatar image too large (max 100KB)', HttpStatus.BAD_REQUEST);
278+
}
279+
return this.usersService.updateAvatar(user.userId, file);
280+
}
248281
}

server/src/users/users.service.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,42 @@ export class UsersService {
2020
return this.prisma.user.findUnique({ where: { username } });
2121
}
2222

23-
async findById(userId: string): Promise<PrismaUser | null> {
24-
return this.prisma.user.findUnique({ where: { id: userId } });
23+
async findById(userId: string): Promise<PartialPrismaUser | null> {
24+
return this.prisma.user.findUnique({
25+
where: {
26+
id: userId
27+
},
28+
select: {
29+
id: true,
30+
username: true,
31+
email: true,
32+
firstName: true,
33+
lastName: true,
34+
createdAt: true,
35+
updatedAt: true,
36+
isActive: true,
37+
lastLogin: true,
38+
lastIp: true,
39+
provider: true,
40+
providerId: true,
41+
providerData: true,
42+
image: true,
43+
role: {
44+
select: {
45+
id: true,
46+
name: true,
47+
description: true
48+
}
49+
},
50+
userGroups: {
51+
select: {
52+
id: true,
53+
name: true,
54+
description: true
55+
}
56+
},
57+
}
58+
});
2559
}
2660

2761
async findAll(): Promise<User[]> {
@@ -191,6 +225,21 @@ export class UsersService {
191225
}
192226
});
193227
}
228+
/*
229+
async generatePasswordHash(password: string): Promise<string> {
230+
const salt = crypto.randomBytes(16).toString('hex');
231+
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
232+
return `${salt}:${hash}`;
233+
}
234+
async verifyPassword(password: string, hash: string): Promise<boolean> {
235+
const [salt, key] = hash.split(':');
236+
const hashVerify = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
237+
return key === hashVerify;
238+
}
239+
async getUserByEmail(email: string): Promise<PrismaUser | null> {
240+
return this.prisma.user.findUnique({ where: { email } });
241+
}
242+
*/
194243

195244
async findAllRoles(): Promise<any[]> {
196245
return this.prisma.role.findMany({
@@ -204,4 +253,23 @@ export class UsersService {
204253
});
205254
}
206255

256+
async updateAvatar(userId: string, avatarFile: any): Promise<PrismaUser | undefined> {
257+
if (!avatarFile || !avatarFile.buffer) {
258+
this.logger.warn('No avatar file buffer provided.');
259+
return undefined;
260+
}
261+
// Store as base64 string in DB (for demo; in production, store in object storage or filesystem)
262+
const base64Image = `data:${avatarFile.mimetype};base64,${avatarFile.buffer.toString('base64')}`;
263+
try {
264+
return await this.prisma.user.update({
265+
where: { id: userId },
266+
data: { image: base64Image },
267+
});
268+
} catch (error) {
269+
this.logger.warn(`User with ID ${userId} not found for avatar update.`);
270+
this.logger.debug(error);
271+
return undefined;
272+
}
273+
}
274+
207275
}

0 commit comments

Comments
 (0)