diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index d36ed7ae..21fac6f8 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -18,7 +18,7 @@ import { 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(); @@ -241,6 +241,72 @@ router.get('/project/:id', isAuthenticated, async (req, res) => { 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, { + apiKey: hashedApiKey, + apiKeyPrefix: apiKeyPrefix, + }); + + const updatedUser = await dalUser.findByUserID(userId); + + 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 diff --git a/backend/src/api/projects.ts b/backend/src/api/projects.ts index 6fb5c271..df744622 100644 --- a/backend/src/api/projects.ts +++ b/backend/src/api/projects.ts @@ -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); diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index 881cb2c7..7cca6a21 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -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); diff --git a/backend/src/repository/dalUser.ts b/backend/src/repository/dalUser.ts index a60959de..76750475 100644 --- a/backend/src/repository/dalUser.ts +++ b/backend/src/repository/dalUser.ts @@ -68,7 +68,9 @@ export const update = async (id: string, user: Partial) => { } }; -export const findByPasswordResetToken = async (token: string): Promise => { +export const findByPasswordResetToken = async ( + token: string +): Promise => { try { const user: UserModel | null = await User.findOne({ resetPasswordToken: token, @@ -103,15 +105,45 @@ export const updatePassword = async ( } }; +export const findByPrefix = async ( + apiPrefix: string +): Promise => { + 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 +): Promise => { + 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; diff --git a/backend/src/utils/Utils.ts b/backend/src/utils/Utils.ts index f2863b76..836476c8 100644 --- a/backend/src/utils/Utils.ts +++ b/backend/src/utils/Utils.ts @@ -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'; \ No newline at end of file +export const TEACHER_POST_COLOR = '#BBC4F7'; diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index 552c9526..759a545a 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -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; @@ -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!'); diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 74567df8..8f5a09dd 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -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, @@ -143,6 +144,7 @@ export function tokenGetter() { ScoreRoomcastingEnvironmentComponent, ForgotPasswordComponent, ResetPasswordComponent, + GenerateApiModalComponent, ], entryComponents: [ ScoreViewModalComponent, diff --git a/frontend/src/app/components/create-workflow-modal/create-workflow-modal.component.ts b/frontend/src/app/components/create-workflow-modal/create-workflow-modal.component.ts index 39286318..dbda2e7a 100644 --- a/frontend/src/app/components/create-workflow-modal/create-workflow-modal.component.ts +++ b/frontend/src/app/components/create-workflow-modal/create-workflow-modal.component.ts @@ -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, diff --git a/frontend/src/app/components/toolbar/toolbar.component.html b/frontend/src/app/components/toolbar/toolbar.component.html index 8314f432..e9402b11 100644 --- a/frontend/src/app/components/toolbar/toolbar.component.html +++ b/frontend/src/app/components/toolbar/toolbar.component.html @@ -1,22 +1,82 @@ - + + + + - - - - - + - \ No newline at end of file + + + + + + diff --git a/frontend/src/app/components/toolbar/toolbar.component.ts b/frontend/src/app/components/toolbar/toolbar.component.ts index b1f3d6a8..0debc6de 100644 --- a/frontend/src/app/components/toolbar/toolbar.component.ts +++ b/frontend/src/app/components/toolbar/toolbar.component.ts @@ -3,8 +3,12 @@ import { Router } from '@angular/router'; import { AuthUser } from 'src/app/models/user'; import { UserService } from 'src/app/services/user.service'; import { Board, BoardScope } from 'src/app/models/board'; // Import Board and BoardScope -import { Project } from 'src/app/models/project'; // Import Project - +import { Project } from 'src/app/models/project'; // Import Project +import { ComponentType } from '@angular/cdk/portal'; +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; +import { GenerateApiModalComponent } from 'src/app/generate-api-modal/generate-api-modal.component'; +import { ConfirmModalComponent } from '../confirm-modal/confirm-modal.component'; +import { SnackbarService } from 'src/app/services/snackbar.service'; @Component({ selector: 'app-toolbar', @@ -25,13 +29,79 @@ export class ToolbarComponent implements OnInit { @Input() project?: Project; // Make project optional BoardScope: typeof BoardScope = BoardScope; //for comparing enum in the template + apiKeyGenerated = false; + + hovering = false; + + constructor( + private userService: UserService, + private snackbarService: SnackbarService, + private router: Router, + public dialog: MatDialog + ) {} + + async ngOnInit(): Promise { + this.apiKeyGenerated = (await this.userService.checkApiKey()) + ? true + : false; + } + + async generateApiKey(): Promise { + const apiKey = await this.userService.generateApiKey(); + this._openDialog(GenerateApiModalComponent, apiKey); + this.apiKeyGenerated = true; + } + + regenerateApiKey(): void { + this._openDialog(ConfirmModalComponent, { + title: 'Regenerate API Key?', + message: + 'Are you sure you want to regenerate your API key? Your previous API Key will be inactive and replaced by a new one. This action is irreversable.', + handleConfirm: () => { + this.generateApiKey(); + }, + confirmLabel: 'Regenerate', + }); + } - constructor(private userService: UserService, private router: Router) {} + deleteApiKey(): void { + this._openDialog(ConfirmModalComponent, { + title: 'Delete API Key?', + message: + 'Are you sure you want to delete your API key? This action is irreversable.', + handleConfirm: () => { + this.userService.deleteApiKey().then(() => { + this.apiKeyGenerated = false; + this.openSnackBar('Your API Key was deleted successfully!'); + }); + }, + confirmLabel: 'Delete', + }); + } - ngOnInit(): void {} + async confirmDeleteApiKey(): Promise { + await this.userService.deleteApiKey(); + this.apiKeyGenerated = false; + } signOut(): void { this.userService.logout(); this.router.navigate(['login']); } -} \ No newline at end of file + + private _openDialog( + component: ComponentType, + data: any, + width = '700px' + ) { + this.dialog.open(component, { + maxWidth: 1280, + width: width, + autoFocus: false, + data: data, + }); + } + openSnackBar(message: string): void { + this.snackbarService.queueSnackbar(message); + } +} diff --git a/frontend/src/app/generate-api-modal/generate-api-modal.component.html b/frontend/src/app/generate-api-modal/generate-api-modal.component.html new file mode 100644 index 00000000..e752f141 --- /dev/null +++ b/frontend/src/app/generate-api-modal/generate-api-modal.component.html @@ -0,0 +1,25 @@ +

Your API Key

+
+ Make sure to save your API key! +
+ You will not be able to access your API key after this. The only option + is to generate a new one! +
+
+ + +
+
+
+ +
diff --git a/frontend/src/app/generate-api-modal/generate-api-modal.component.scss b/frontend/src/app/generate-api-modal/generate-api-modal.component.scss new file mode 100644 index 00000000..31ebe3c6 --- /dev/null +++ b/frontend/src/app/generate-api-modal/generate-api-modal.component.scss @@ -0,0 +1,31 @@ +.api-key-container { + display: flex; + align-items: center; + gap: 8px; + max-width: 300px; +} + +.api-key-box { + flex: 1; + padding: 4px 8px; + font-family: monospace; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f5f5f5; + cursor: default; +} + +.copy-button { + padding: 4px 10px; + font-size: 14px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.copy-button:hover { + background-color: #0056b3; +} diff --git a/frontend/src/app/generate-api-modal/generate-api-modal.component.spec.ts b/frontend/src/app/generate-api-modal/generate-api-modal.component.spec.ts new file mode 100644 index 00000000..b84474d1 --- /dev/null +++ b/frontend/src/app/generate-api-modal/generate-api-modal.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GenerateApiModalComponent } from './generate-api-modal.component'; + +describe('GenerateApiModalComponent', () => { + let component: GenerateApiModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ GenerateApiModalComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GenerateApiModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/generate-api-modal/generate-api-modal.component.ts b/frontend/src/app/generate-api-modal/generate-api-modal.component.ts new file mode 100644 index 00000000..8671a878 --- /dev/null +++ b/frontend/src/app/generate-api-modal/generate-api-modal.component.ts @@ -0,0 +1,31 @@ +import { Component, Inject } from '@angular/core'; +import { + MatLegacyDialogRef as MatDialogRef, + MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA, +} from '@angular/material/legacy-dialog'; +@Component({ + selector: 'app-generate-api-modal', + templateUrl: './generate-api-modal.component.html', + styleUrls: ['./generate-api-modal.component.scss'], +}) +export class GenerateApiModalComponent { + apiKey: string; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: string + ) { + this.apiKey = data; + } + + copyApiKey() { + navigator.clipboard + .writeText(this.apiKey) + .then(() => { + console.log('API key copied to clipboard!'); + }) + .catch((err) => { + console.error('Failed to copy API key: ', err); + }); + } +} diff --git a/frontend/src/app/services/user.service.ts b/frontend/src/app/services/user.service.ts index f41c3c32..7b61584f 100644 --- a/frontend/src/app/services/user.service.ts +++ b/frontend/src/app/services/user.service.ts @@ -122,6 +122,34 @@ export class UserService { }); } + async generateApiKey(): Promise { + return this.http + .get(`auth/generate-api-key/` + this.user?.userID) + .toPromise() + .then((result: any) => { + if (result) { + return result; + } + }); + } + + async deleteApiKey() { + return this.http + .delete(`auth/delete-api-key/` + this.user?.userID) + .toPromise(); + } + + async checkApiKey(): Promise { + return this.http + .get(`auth/check-api-key/` + this.user?.userID) + .toPromise() + .then((result: any) => { + if (result) { + return result; + } + }); + } + logout() { localStorage.removeItem('user'); localStorage.removeItem('access_token');