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
68 changes: 67 additions & 1 deletion backend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import { UserModel } from '../models/User';
import dalProject from '../repository/dalProject';
import { generateEmail } from '../utils/email';
import { generateUniqueID } from '../utils/Utils';
import { generateApiKey, generateUniqueID } from '../utils/Utils';

const router = Router();

Expand Down Expand Up @@ -107,8 +107,8 @@

// 5. Send an email to the user with a link containing the token
const resetLink = `${
process.env.CKBOARD_SERVER_ADDRESS!.startsWith('http') ? '' : 'https://'

Check warning on line 110 in backend/src/api/auth.ts

View workflow job for this annotation

GitHub Actions / Linting and Code Formating Check CI (backend)

Forbidden non-null assertion
}${process.env.CKBOARD_SERVER_ADDRESS!}/reset-password?token=${resetToken}`;

Check warning on line 111 in backend/src/api/auth.ts

View workflow job for this annotation

GitHub Actions / Linting and Code Formating Check CI (backend)

Forbidden non-null assertion

try {
await generateEmail(email, 'Password Reset Request', resetLink);
Expand Down Expand Up @@ -241,6 +241,72 @@
res.status(200).json(users);
});

router.get('/generate-api-key/:id', isAuthenticated, async (req, res) => {
console.log('here');
const userId = req.params.id;

// 1. Find the user by the id
const user = await dalUser.findByUserID(userId);

if (!user) {
return res.status(400).send({ message: 'User not authorized' });
}

// 2. Generate the api key
const apiKey = generateApiKey();

// 3. Hash the api key
const hashedApiKey = await bcrypt.hash(apiKey, 10);

// 4. Get an api key prefix
const apiKeyPrefix = apiKey.substring(0, 5);

// 5. Update the user's api key and prefix
const newUser = await dalUser.update(userId, {

Check warning on line 265 in backend/src/api/auth.ts

View workflow job for this annotation

GitHub Actions / Linting and Code Formating Check CI (backend)

'newUser' is assigned a value but never used
apiKey: hashedApiKey,
apiKeyPrefix: apiKeyPrefix,
});

const updatedUser = await dalUser.findByUserID(userId);

Check warning on line 270 in backend/src/api/auth.ts

View workflow job for this annotation

GitHub Actions / Linting and Code Formating Check CI (backend)

'updatedUser' is assigned a value but never used

res.status(200).json(apiKey);
});

router.get('/check-api-key/:id', isAuthenticated, async (req, res) => {
const userId = req.params.id;

const user = await dalUser.findByUserID(userId);

if (!user) {
return res.status(400).send({ message: 'User not authorized' });
}

if (user.apiKey != undefined) {
return res.status(200).json(true);
} else {
return res.status(200).json(false);
}
});

router.delete('/delete-api-key/:id', isAuthenticated, async (req, res) => {
const userId = req.params.id;

// 1. Find the user by the id
const user = await dalUser.findByUserID(userId);

if (!user) {
return res.status(400).send({ message: 'User not authorized' });
}

// 2. Delete the user's api key and prefix
await dalUser.deleteFields(userId, {
apiKey: '',
apiKeyPrefix: '',
});

res.status(200).json(true);
});

router.patch('/:boardID/currentView', async (req, res) => {
const { boardID } = req.params;
const { viewType } = req.body; // Expect viewType in the request body
Expand Down
7 changes: 7 additions & 0 deletions backend/src/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ router.get('/users/:id', async (req, res) => {
res.status(200).json(projects);
});

router.get('/', async (req, res) => {
const id = res.locals.user.userID;

const projects = await dalProject.getByUserId(id);
res.status(200).json(projects);
});

router.delete('/:id', async (req, res) => {
const { id } = req.params;
const deletedProject = await dalProject.remove(id);
Expand Down
6 changes: 6 additions & 0 deletions backend/src/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export class UserModel {

@prop({ required: false })
public currentView?: ViewType;

@prop({ required: false })
public apiKey?: string;

@prop({ required: false })
public apiKeyPrefix?: string;
}

export default getModelForClass(UserModel);
36 changes: 34 additions & 2 deletions backend/src/repository/dalUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export const update = async (id: string, user: Partial<UserModel>) => {
}
};

export const findByPasswordResetToken = async (token: string): Promise<UserModel | null> => {
export const findByPasswordResetToken = async (
token: string
): Promise<UserModel | null> => {
try {
const user: UserModel | null = await User.findOne({
resetPasswordToken: token,
Expand Down Expand Up @@ -103,15 +105,45 @@ export const updatePassword = async (
}
};

export const findByPrefix = async (
apiPrefix: string
): Promise<UserModel | null> => {
try {
const user: UserModel | null = await User.findOne({
apiKeyPrefix: apiPrefix,
});
return user;
} catch (err) {
throw new Error(JSON.stringify(err, null, ' '));
}
};

export const deleteFields = async (
id: string,
fieldsToDelete: Partial<UserModel>
): Promise<UserModel | null> => {
try {
const updated = await User.findOneAndUpdate(
{ userID: id },
{ $unset: fieldsToDelete }
);
return updated;
} catch (err) {
throw new Error(JSON.stringify(err, null, ' '));
}
};

const dalUser = {
findByUserID,
findByUserIDs,
findByUsername,
findByEmail,
findByPrefix,
create,
update,
findByPasswordResetToken,
updatePassword
updatePassword,
deleteFields,
};

export default dalUser;
7 changes: 6 additions & 1 deletion backend/src/utils/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';

export const generateUniqueID = () => {
return uuidv4();
};

export function generateApiKey(): string {
return crypto.randomBytes(32).toString('hex');
}

export const STUDENT_POST_COLOR = '#FFF7C0';
export const TEACHER_POST_COLOR = '#BBC4F7';
export const TEACHER_POST_COLOR = '#BBC4F7';
18 changes: 17 additions & 1 deletion backend/src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ProjectModel } from '../models/Project';
import { NotFoundError } from '../errors/client.errors';
import { addUserToProject } from './project.helpers';
import { ApplicationError } from '../errors/base.errors';
import bcrypt from 'bcrypt';

export interface Token {
email: string;
Expand Down Expand Up @@ -49,13 +50,28 @@ export const isAuthenticated = async (
next: NextFunction
) => {
try {
const apiKey = req.header('x-api-key');
if (apiKey) {
const keyPrefix = apiKey.substring(0, 5);
const user = await dalUser.findByPrefix(keyPrefix);
if (!user || !user.apiKey) return res.status(403).end('Invalid API key!');
if (await bcrypt.compare(apiKey, user.apiKey)) {
res.locals.user = {
email: user.email,
username: user.username,
userID: user.userID,
role: user.role,
};
return next();
}
}

if (!req.headers.authorization) {
return res.status(400).end('No authorization header found!');
}

const token = req.headers.authorization.replace('Bearer ', '');
res.locals.user = verify(token, getJWTSecret()) as Token;

next();
} catch (e) {
return res.status(403).end('Unable to authenticate!');
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { ShowJoinCodeComponent } from './components/show-join-code/show-join-cod
import { ScoreRoomcastingEnvironmentComponent } from './components/score-roomcasting-environment/score-roomcasting-environment.component';
import { ForgotPasswordComponent } from './components/forgot-password/forgot-password.component';
import { ResetPasswordComponent } from './components/reset-password/reset-password.component';
import { GenerateApiModalComponent } from './generate-api-modal/generate-api-modal.component';

const config: SocketIoConfig = {
url: environment.socketUrl,
Expand Down Expand Up @@ -143,6 +144,7 @@ export function tokenGetter() {
ScoreRoomcastingEnvironmentComponent,
ForgotPasswordComponent,
ResetPasswordComponent,
GenerateApiModalComponent,
],
entryComponents: [
ScoreViewModalComponent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,6 @@ export class CreateWorkflowModalComponent implements OnInit, OnDestroy {
this.changeDetectorRef.detectChanges();
this.scrollToBottom();

console.log(this.board.boardID, this.user.userID);
// 2. Send data and prompt to the backend via WebSocket
this.socketService.emit(SocketEvent.AI_MESSAGE, {
posts,
Expand Down
92 changes: 76 additions & 16 deletions frontend/src/app/components/toolbar/toolbar.component.html
Original file line number Diff line number Diff line change
@@ -1,22 +1,82 @@
<mat-toolbar color="primary" role="toolbar" aria-label="Top Toolbar">
<ng-content></ng-content>

<button mat-icon-button [matMenuTriggerFor]="userMenu" matTooltip="Your Profile" *ngIf="showSignOut">
<mat-icon>account_circle</mat-icon>
<button
mat-icon-button
[matMenuTriggerFor]="userMenu"
matTooltip="Your Profile"
*ngIf="showSignOut"
>
<mat-icon>account_circle</mat-icon>
</button>
<mat-menu #userMenu="matMenu">
<div class="user-info" style="text-align: center; padding: 10px">
<mat-icon style="font-size: 48px; width: 48px; height: 48px"
>account_circle</mat-icon
>
<h3 style="margin: 8px 0">{{ user.username }}</h3>
<p style="margin: 4px 0; color: gray">{{ user.role }}</p>
</div>
<mat-divider></mat-divider>
<button
*ngIf="apiKeyGenerated === false"
mat-menu-item
(click)="generateApiKey()"
>
<mat-icon>api</mat-icon>
<span>Generate an API Key</span>
</button>
<mat-menu #userMenu="matMenu">
<div class="user-info" style="text-align: center; padding: 10px;">
<mat-icon style="font-size: 48px; width: 48px; height: 48px;">account_circle</mat-icon>
<h3 style="margin: 8px 0;">{{ user.username }}</h3>
<p style="margin: 4px 0; color: gray;">{{ user.role }}</p>
</div>
<mat-divider></mat-divider>
<button mat-menu-item (click)="signOut()">
<mat-icon>exit_to_app</mat-icon>
<span>Sign Out</span>
</button>
</mat-menu>
<button
*ngIf="apiKeyGenerated === true"
mat-menu-item
(click)="regenerateApiKey()"
>
<span
style="
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
"
>
<span style="flex: 1"
><mat-icon>api</mat-icon>Regenerate an API Key</span
>

<ng-content select="[navbarMenu]"></ng-content>
<!-- Delete icon button styled independently -->
<span
(click)="$event.stopPropagation(); deleteApiKey()"
style="
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-left: 12px;
border-radius: 4px;
transition: background-color 0.2s;
cursor: pointer;
box-sizing: content-box;
"
(mouseenter)="hovering = true"
(mouseleave)="hovering = false"
[ngStyle]="{
backgroundColor: hovering ? '#f8d7da' : 'transparent'
}"
>
<mat-icon style="color: red; overflow: visible; margin: 0"
>delete</mat-icon
>
</span>
</span>
</button>

</mat-toolbar>
<mat-divider></mat-divider>
<button mat-menu-item (click)="signOut()">
<mat-icon>exit_to_app</mat-icon>
<span>Sign Out</span>
</button>
</mat-menu>

<ng-content select="[navbarMenu]"></ng-content>
</mat-toolbar>
Loading
Loading