Added User and Authentication, refactored IDs to use UUID
Some checks failed
Music Collection CI Workflow / test (./backend) (push) Failing after 54s
Music Collection CI Workflow / test (./frontend) (push) Failing after 37s
Music Collection CI Workflow / build-and-push-images (./backend/Dockerfile, git.anatid.net/tabris/music-collection-backend, ./backend) (push) Has been skipped
Music Collection CI Workflow / build-and-push-images (./frontend/Dockerfile, git.anatid.net/tabris/music-collection-frontend, ./frontend) (push) Has been skipped
Music Collection CI Workflow / deploy (push) Has been skipped
Some checks failed
Music Collection CI Workflow / test (./backend) (push) Failing after 54s
Music Collection CI Workflow / test (./frontend) (push) Failing after 37s
Music Collection CI Workflow / build-and-push-images (./backend/Dockerfile, git.anatid.net/tabris/music-collection-backend, ./backend) (push) Has been skipped
Music Collection CI Workflow / build-and-push-images (./frontend/Dockerfile, git.anatid.net/tabris/music-collection-frontend, ./frontend) (push) Has been skipped
Music Collection CI Workflow / deploy (push) Has been skipped
This commit is contained in:
parent
07a04239e5
commit
a51fd6997e
945
backend/package-lock.json
generated
945
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -24,13 +24,21 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.1.1",
|
"@nestjs/swagger": "^11.1.1",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nestjs-uuid": "^0.1.4",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
@ -45,6 +53,7 @@
|
|||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@swc/cli": "^0.6.0",
|
"@swc/cli": "^0.6.0",
|
||||||
"@swc/core": "^1.10.7",
|
"@swc/core": "^1.10.7",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
@ -14,6 +14,7 @@ import { AlbumService } from './album.service';
|
|||||||
import { Album } from './album.entity';
|
import { Album } from './album.entity';
|
||||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||||
|
import {UUID} from "crypto";
|
||||||
|
|
||||||
@Controller('album')
|
@Controller('album')
|
||||||
export class AlbumController {
|
export class AlbumController {
|
||||||
@ -24,9 +25,9 @@ export class AlbumController {
|
|||||||
return this.albumService.findAll();
|
return this.albumService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':uuid')
|
||||||
findOneById(@Param('id') id: number): Promise<Album | string | null> {
|
findOneById(@Param('uuid') uuid: UUID): Promise<Album | string | null> {
|
||||||
return this.albumService.findOneById(id);
|
return this.albumService.findOneById(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -35,17 +36,17 @@ export class AlbumController {
|
|||||||
return this.albumService.create(createAlbumDto);
|
return this.albumService.create(createAlbumDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':uuid')
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async update(
|
async update(
|
||||||
@Param('id') id: number,
|
@Param('uuid') uuid: UUID,
|
||||||
@Body() updateAlbumDto: UpdateAlbumDto,
|
@Body() updateAlbumDto: UpdateAlbumDto,
|
||||||
): Promise<Album | string | null> {
|
): Promise<Album | string | null> {
|
||||||
return this.albumService.update(id, updateAlbumDto);
|
return this.albumService.update(uuid, updateAlbumDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':uuid')
|
||||||
async remove(@Param('id') id: number): Promise<DeleteResult | string | null> {
|
async remove(@Param('uuid') uuid: UUID): Promise<DeleteResult | string | null> {
|
||||||
return this.albumService.remove(id);
|
return this.albumService.remove(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { Entity, Column, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
import {Entity, Column, OneToMany, PrimaryGeneratedColumn, Generated} from 'typeorm';
|
||||||
import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
|
import { IsNotEmpty, IsString, IsOptional } from 'class-validator';
|
||||||
import { Song } from '../song/song.entity';
|
import { Song } from '../song/song.entity';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
|
||||||
@Entity('album')
|
@Entity('album')
|
||||||
export class Album {
|
export class Album {
|
||||||
|
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
@Generated("uuid")
|
||||||
|
uuid: UUID;
|
||||||
|
|
||||||
@Column({ unique: true })
|
@Column({ unique: true })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -6,7 +6,9 @@ import { AlbumService } from './album.service';
|
|||||||
import { APP_PIPE } from '@nestjs/core';
|
import { APP_PIPE } from '@nestjs/core';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Album])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Album]),
|
||||||
|
],
|
||||||
controllers: [AlbumController],
|
controllers: [AlbumController],
|
||||||
providers: [
|
providers: [
|
||||||
AlbumService,
|
AlbumService,
|
||||||
|
@ -4,6 +4,7 @@ import { DeleteResult, Repository } from 'typeorm';
|
|||||||
import {Album} from './album.entity';
|
import {Album} from './album.entity';
|
||||||
import {CreateAlbumDto} from './dto/create-album.dto';
|
import {CreateAlbumDto} from './dto/create-album.dto';
|
||||||
import {UpdateAlbumDto} from './dto/update-album.dto';
|
import {UpdateAlbumDto} from './dto/update-album.dto';
|
||||||
|
import {UUID} from "crypto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AlbumService {
|
export class AlbumService {
|
||||||
@ -28,11 +29,11 @@ export class AlbumService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
findOneById(id: number): Promise<Album | string | null> {
|
findOneById(uuid: UUID): Promise<Album | string | null> {
|
||||||
return this.albumRepository
|
return this.albumRepository
|
||||||
.findOne({
|
.findOne({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
uuid: uuid,
|
||||||
},
|
},
|
||||||
relations: {
|
relations: {
|
||||||
songs: true,
|
songs: true,
|
||||||
@ -47,10 +48,10 @@ export class AlbumService {
|
|||||||
return album;
|
return album;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
return `There was a problem creating the Album identified by ID ${id}: ${error}`;
|
return `There was a problem creating the Album identified by ID ${uuid}: ${error}`;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
return `There was a problem creating the Album identified by ID ${id}`;
|
return `There was a problem creating the Album identified by ID ${uuid}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,48 +72,47 @@ export class AlbumService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
id: number,
|
uuid: string,
|
||||||
updateAlbumDto: UpdateAlbumDto,
|
updateAlbumDto: UpdateAlbumDto,
|
||||||
): Promise<Album | string | null> {
|
): Promise<Album | string | null> {
|
||||||
if (id == updateAlbumDto.id) {
|
if (uuid == updateAlbumDto.uuid) {
|
||||||
const albumToUpdate = await this.albumRepository.findOneBy({
|
const albumToUpdate = await this.albumRepository.findOneBy({
|
||||||
id: updateAlbumDto.id,
|
uuid: updateAlbumDto.uuid,
|
||||||
});
|
});
|
||||||
if (!albumToUpdate) {
|
if (!albumToUpdate) {
|
||||||
console.error("AlbumService: update: Didn't find album: ", id);
|
console.error("AlbumService: update: Didn't find album: ", uuid);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.albumRepository.update(
|
await this.albumRepository.update(
|
||||||
{ id: updateAlbumDto.id },
|
{ uuid: updateAlbumDto.uuid },
|
||||||
{
|
{
|
||||||
title: updateAlbumDto.title,
|
title: updateAlbumDto.title,
|
||||||
artist: updateAlbumDto.artist,
|
artist: updateAlbumDto.artist,
|
||||||
genre: updateAlbumDto.genre,
|
genre: updateAlbumDto.genre,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const album = await this.albumRepository.findOneBy({
|
return await this.albumRepository.findOneBy({
|
||||||
id: updateAlbumDto.id,
|
uuid: updateAlbumDto.uuid,
|
||||||
});
|
});
|
||||||
return album;
|
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
'AlbumService: update: IDs do not match',
|
'AlbumService: update: IDs do not match',
|
||||||
id,
|
uuid,
|
||||||
updateAlbumDto,
|
updateAlbumDto,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number): Promise<DeleteResult | string | null> {
|
async remove(uuid: UUID): Promise<DeleteResult | string | null> {
|
||||||
return await this.albumRepository
|
return await this.albumRepository
|
||||||
.delete(id)
|
.delete(uuid)
|
||||||
.then((deleteResult) => {
|
.then((deleteResult) => {
|
||||||
return deleteResult;
|
return deleteResult;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
return `There was a problem deleting the Album identified by ID ${id}: ${error}`;
|
return `There was a problem deleting the Album identified by ID ${uuid}: ${error}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
import {IsNotEmpty, IsString, IsUUID} from 'class-validator';
|
||||||
|
import {UUID} from "crypto";
|
||||||
|
|
||||||
export class UpdateAlbumDto {
|
export class UpdateAlbumDto {
|
||||||
@IsNumber()
|
@IsUUID()
|
||||||
id: number;
|
uuid: UUID;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ -6,17 +6,31 @@ import { AppService } from './app.service';
|
|||||||
import { DatabaseModule } from '@/database/database.module';
|
import { DatabaseModule } from '@/database/database.module';
|
||||||
import { AlbumModule } from '@/album/album.module';
|
import { AlbumModule } from '@/album/album.module';
|
||||||
import { SongModule } from '@/song/song.module';
|
import { SongModule } from '@/song/song.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import {JwtGuard} from "@/auth/guards/jwt.guard";
|
||||||
|
import {APP_GUARD} from "@nestjs/core";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
load: [configuration],
|
load: [configuration],
|
||||||
}),
|
}),
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AlbumModule,
|
AlbumModule,
|
||||||
SongModule,
|
SongModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [
|
||||||
|
AppService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
18
backend/src/auth/auth.controller.spec.ts
Normal file
18
backend/src/auth/auth.controller.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
|
||||||
|
describe('AuthController', () => {
|
||||||
|
let controller: AuthController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AuthController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
4
backend/src/auth/auth.controller.ts
Normal file
4
backend/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {}
|
22
backend/src/auth/auth.module.ts
Normal file
22
backend/src/auth/auth.module.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
import {UsersModule} from "@/users/users.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.register({
|
||||||
|
secret: 'secret',
|
||||||
|
signOptions: { expiresIn: '60s' }
|
||||||
|
}),
|
||||||
|
UsersModule
|
||||||
|
],
|
||||||
|
exports: [JwtModule, PassportModule]
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
18
backend/src/auth/auth.service.spec.ts
Normal file
18
backend/src/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [AuthService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
43
backend/src/auth/auth.service.ts
Normal file
43
backend/src/auth/auth.service.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import {BadRequestException, Injectable, UnauthorizedException} from '@nestjs/common';
|
||||||
|
import {UsersService} from "@/users/users.service";
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import {User} from "@/users/user.entity";
|
||||||
|
import {JwtService} from "@nestjs/jwt";
|
||||||
|
import {AccessToken} from "@/auth/types/AccessToken.type";
|
||||||
|
import {RegisterRequestDto} from "@/auth/dto/register-request.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private usersService: UsersService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateUser(email: string, password: string): Promise<User> {
|
||||||
|
const user = await this.usersService.findOneByEmail(email);
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('User not found');
|
||||||
|
}
|
||||||
|
const isMatch: boolean = bcrypt.compareSync(password, user.password);
|
||||||
|
if (!isMatch) {
|
||||||
|
throw new BadRequestException('Password does not match');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(user: User): Promise<AccessToken> {
|
||||||
|
const payload = { email: user.email, uuid: user.uuid };
|
||||||
|
return { access_token: this.jwtService.sign(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(user: RegisterRequestDto): Promise<AccessToken> {
|
||||||
|
const existingUser = await this.usersService.findOneByEmail(user.email);
|
||||||
|
if (existingUser) {
|
||||||
|
throw new BadRequestException('email already exists');
|
||||||
|
}
|
||||||
|
const hashedPassword = await bcrypt.hash(user.password, 10);
|
||||||
|
const newUser: User = { ...user, password: hashedPassword };
|
||||||
|
await this.usersService.create(newUser);
|
||||||
|
return this.login(newUser);
|
||||||
|
}
|
||||||
|
}
|
3
backend/src/auth/dto/login-response.dto.ts
Normal file
3
backend/src/auth/dto/login-response.dto.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { AccessToken } from '../types/AccessToken.type';
|
||||||
|
|
||||||
|
export type LoginResponseDTO = AccessToken;
|
7
backend/src/auth/dto/register-request.dto.ts
Normal file
7
backend/src/auth/dto/register-request.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export type RegisterRequestDto = {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
3
backend/src/auth/dto/register-response.dto.ts
Normal file
3
backend/src/auth/dto/register-response.dto.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { AccessToken } from '../types/AccessToken.type';
|
||||||
|
|
||||||
|
export type RegisterResponseDTO = AccessToken;
|
21
backend/src/auth/guards/jwt.guard.ts
Normal file
21
backend/src/auth/guards/jwt.guard.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride('isPublic', [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
16
backend/src/auth/jwt.strategy.ts
Normal file
16
backend/src/auth/jwt.strategy.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: 'secretKey',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: any) {
|
||||||
|
return { userId: payload.sub, username: payload.username };
|
||||||
|
}
|
||||||
|
}
|
3
backend/src/auth/types/AccessToken.type.ts
Normal file
3
backend/src/auth/types/AccessToken.type.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export type AccessToken = {
|
||||||
|
access_token: string;
|
||||||
|
};
|
6
backend/src/auth/types/AccessTokenPayload.type.ts
Normal file
6
backend/src/auth/types/AccessTokenPayload.type.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { UUID } from 'crypto';
|
||||||
|
|
||||||
|
export type AccessTokenPayload = {
|
||||||
|
userId: UUID;
|
||||||
|
email: string;
|
||||||
|
};
|
@ -1,16 +1,18 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import {NestFactory, Reflector} from '@nestjs/core';
|
||||||
import { BadRequestException, ValidationError, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ValidationError, ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AlbumModule } from './album/album.module';
|
import { AlbumModule } from './album/album.module';
|
||||||
import { SongModule } from './song/song.module';
|
import { SongModule } from './song/song.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {JwtGuard} from "@/auth/guards/jwt.guard";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ['error', 'warn'],
|
logger: ['error', 'warn'],
|
||||||
});
|
});
|
||||||
const configService = app.get<ConfigService>(ConfigService);
|
const configService = app.get<ConfigService>(ConfigService);
|
||||||
|
app.useGlobalGuards(new JwtGuard(app.get(Reflector)));
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: [configService.get<string>('app.frontend_url')],
|
origin: [configService.get<string>('app.frontend_url')],
|
||||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
import {IsNotEmpty, IsNumber, IsString, IsUUID} from 'class-validator';
|
||||||
|
import {UUID} from "crypto";
|
||||||
|
|
||||||
export class CreateSongDto {
|
export class CreateSongDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -11,6 +12,6 @@ export class CreateSongDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
trackNumber: number;
|
trackNumber: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsUUID()
|
||||||
albumId: number;
|
albumId: UUID;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
import {IsNotEmpty, IsNumber, IsString, IsUUID} from 'class-validator';
|
||||||
|
import {UUID} from "crypto";
|
||||||
|
|
||||||
export class UpdateSongDto {
|
export class UpdateSongDto {
|
||||||
@IsNumber()
|
@IsUUID()
|
||||||
id: number;
|
uuid: UUID;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ -14,6 +15,6 @@ export class UpdateSongDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
trackNumber: number;
|
trackNumber: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsUUID()
|
||||||
albumId: number;
|
albumId: UUID;
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import { SongService } from './song.service';
|
|||||||
import { Song } from './song.entity';
|
import { Song } from './song.entity';
|
||||||
import { CreateSongDto } from './dto/create-song.dto';
|
import { CreateSongDto } from './dto/create-song.dto';
|
||||||
import { UpdateSongDto } from './dto/update-song.dto';
|
import { UpdateSongDto } from './dto/update-song.dto';
|
||||||
|
import {UUID} from "crypto";
|
||||||
|
|
||||||
@Controller('song')
|
@Controller('song')
|
||||||
export class SongController {
|
export class SongController {
|
||||||
@ -24,9 +25,9 @@ export class SongController {
|
|||||||
return this.songService.findAll();
|
return this.songService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':uuid')
|
||||||
findOneById(@Param('id') id: number): Promise<Song | string | null> {
|
findOneById(@Param('uuid') uuid: UUID): Promise<Song | string | null> {
|
||||||
return this.songService.findOneById(id);
|
return this.songService.findOneById(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -35,18 +36,18 @@ export class SongController {
|
|||||||
return this.songService.create(createSongDto);
|
return this.songService.create(createSongDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':uuid')
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async update(
|
async update(
|
||||||
@Param('id') id: number,
|
@Param('uuid') uuid: UUID,
|
||||||
@Body() updateSongDto: UpdateSongDto,
|
@Body() updateSongDto: UpdateSongDto,
|
||||||
): Promise<Song | string | null> {
|
): Promise<Song | string | null> {
|
||||||
console.log(updateSongDto);
|
console.log(updateSongDto);
|
||||||
return this.songService.update(id, updateSongDto);
|
return this.songService.update(uuid, updateSongDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':uuid')
|
||||||
async remove(@Param('id') id: number): Promise<DeleteResult | string | null> {
|
async remove(@Param('uuid') uuid: UUID): Promise<DeleteResult | string | null> {
|
||||||
return this.songService.remove(id);
|
return this.songService.remove(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { Entity, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
import {Entity, Column, ManyToOne, PrimaryGeneratedColumn, Generated} from 'typeorm';
|
||||||
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||||
import { Album } from '../album/album.entity';
|
import { Album } from '../album/album.entity';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
|
||||||
@Entity('song')
|
@Entity('song')
|
||||||
export class Song {
|
export class Song {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
@Generated("uuid")
|
||||||
|
uuid: UUID;
|
||||||
|
|
||||||
@Column({ unique: true })
|
@Column({ unique: true })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -5,6 +5,7 @@ import { Album } from '../album/album.entity';
|
|||||||
import { Song } from './song.entity';
|
import { Song } from './song.entity';
|
||||||
import { CreateSongDto } from './dto/create-song.dto';
|
import { CreateSongDto } from './dto/create-song.dto';
|
||||||
import { UpdateSongDto } from './dto/update-song.dto';
|
import { UpdateSongDto } from './dto/update-song.dto';
|
||||||
|
import {UUID} from "crypto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SongService {
|
export class SongService {
|
||||||
@ -30,20 +31,20 @@ export class SongService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
findOneById(id: number): Promise<Song | string | null> {
|
findOneById(uuid: UUID): Promise<Song | string | null> {
|
||||||
return this.songRepository
|
return this.songRepository
|
||||||
.findOneBy({ id: id })
|
.findOneBy({ uuid: uuid })
|
||||||
.then((albums) => {
|
.then((albums) => {
|
||||||
return albums;
|
return albums;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
return `There was a problem getting the song identified by ID ${id}: ${error}`;
|
return `There was a problem getting the song identified by ID ${uuid}: ${error}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(createSongDto: CreateSongDto): Promise<Song | string | null> {
|
async create(createSongDto: CreateSongDto): Promise<Song | string | null> {
|
||||||
const album = await this.albumRepository.findOneBy({
|
const album = await this.albumRepository.findOneBy({
|
||||||
id: createSongDto.albumId,
|
uuid: createSongDto.albumId,
|
||||||
});
|
});
|
||||||
if (album) {
|
if (album) {
|
||||||
const song = new Song();
|
const song = new Song();
|
||||||
@ -65,15 +66,15 @@ export class SongService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
id: number,
|
uuid: UUID,
|
||||||
updateSongDto: UpdateSongDto,
|
updateSongDto: UpdateSongDto,
|
||||||
): Promise<Song | string | null> {
|
): Promise<Song | string | null> {
|
||||||
if (id == updateSongDto.id) {
|
if (uuid == updateSongDto.uuid) {
|
||||||
const album = await this.albumRepository.findOneBy({
|
const album = await this.albumRepository.findOneBy({
|
||||||
id: updateSongDto.albumId,
|
uuid: updateSongDto.albumId,
|
||||||
});
|
});
|
||||||
const songToUpdate = await this.songRepository.findOneBy({
|
const songToUpdate = await this.songRepository.findOneBy({
|
||||||
id: updateSongDto.id,
|
uuid: updateSongDto.uuid,
|
||||||
});
|
});
|
||||||
if (!songToUpdate || !album) {
|
if (!songToUpdate || !album) {
|
||||||
console.error('SongService: update: Song or Album not found');
|
console.error('SongService: update: Song or Album not found');
|
||||||
@ -81,7 +82,7 @@ export class SongService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.songRepository.update(
|
await this.songRepository.update(
|
||||||
{ id: updateSongDto.id },
|
{ uuid: updateSongDto.uuid },
|
||||||
{
|
{
|
||||||
title: updateSongDto.title,
|
title: updateSongDto.title,
|
||||||
duration: updateSongDto.duration,
|
duration: updateSongDto.duration,
|
||||||
@ -90,7 +91,7 @@ export class SongService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const song = await this.songRepository.findOneBy({
|
const song = await this.songRepository.findOneBy({
|
||||||
id: updateSongDto.id,
|
uuid: updateSongDto.uuid,
|
||||||
});
|
});
|
||||||
return song;
|
return song;
|
||||||
} else {
|
} else {
|
||||||
@ -99,14 +100,14 @@ export class SongService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number): Promise<DeleteResult | string | null> {
|
async remove(uuid: UUID): Promise<DeleteResult | string | null> {
|
||||||
return await this.songRepository
|
return await this.songRepository
|
||||||
.delete(id)
|
.delete(uuid)
|
||||||
.then((deleteResult) => {
|
.then((deleteResult) => {
|
||||||
return deleteResult;
|
return deleteResult;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
return `There was a problem deleting the Song identified by ID ${id}: ${error}`;
|
return `There was a problem deleting the Song identified by ID ${uuid}: ${error}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
36
backend/src/users/user.entity.ts
Normal file
36
backend/src/users/user.entity.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {Entity, Column, PrimaryGeneratedColumn, Generated, BeforeInsert} from 'typeorm';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import { UUID } from 'crypto';
|
||||||
|
|
||||||
|
@Entity('user')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Generated("uuid")
|
||||||
|
uuid?: UUID;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
}
|
26
backend/src/users/users.module.ts
Normal file
26
backend/src/users/users.module.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import {Module, ValidationPipe} from '@nestjs/common';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import {TypeOrmModule} from "@nestjs/typeorm";
|
||||||
|
import {Song} from "@/song/song.entity";
|
||||||
|
import {AlbumModule} from "@/album/album.module";
|
||||||
|
import {User} from "@/users/user.entity";
|
||||||
|
import {APP_PIPE} from "@nestjs/core";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([User])
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
UsersService,
|
||||||
|
{
|
||||||
|
provide: APP_PIPE,
|
||||||
|
useValue: new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [UsersService, TypeOrmModule],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
18
backend/src/users/users.service.spec.ts
Normal file
18
backend/src/users/users.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
describe('UsersService', () => {
|
||||||
|
let service: UsersService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [UsersService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UsersService>(UsersService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
28
backend/src/users/users.service.ts
Normal file
28
backend/src/users/users.service.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {InjectRepository} from "@nestjs/typeorm";
|
||||||
|
import {Repository} from "typeorm";
|
||||||
|
import {User} from "@/users/user.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findOneByUsername(username: string): Promise<User | null> {
|
||||||
|
return this.userRepository.findOneBy({
|
||||||
|
username: username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneByEmail(email: string): Promise<User | null> {
|
||||||
|
return this.userRepository.findOneBy({
|
||||||
|
email: email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: User): Promise<User> {
|
||||||
|
return this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
@ -56,17 +56,15 @@ export async function getAlbum(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAlbum(formData: FormData) {
|
export async function createAlbum(formData: { id: string; title: string; artist: string; genre: string }) {
|
||||||
const title = formData.get('title');
|
console.log("POSTING Album");
|
||||||
const artist = formData.get('artist');
|
|
||||||
const genre = formData.get('genre');
|
|
||||||
return fetch(`${backendUrl}/album/`, {
|
return fetch(`${backendUrl}/album/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title,
|
title: formData.title,
|
||||||
artist: artist,
|
artist: formData.artist,
|
||||||
genre: genre,
|
genre: formData.genre,
|
||||||
})
|
})
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -78,7 +76,7 @@ export async function createAlbum(formData: FormData) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAlbum(formData: FormData) {
|
export async function updateAlbum(formData: { id: string; title: string; artist: string; genre: string }) {
|
||||||
const id = Number(formData.get('id'));
|
const id = Number(formData.get('id'));
|
||||||
const title = formData.get('title');
|
const title = formData.get('title');
|
||||||
const artist = formData.get('artist');
|
const artist = formData.get('artist');
|
||||||
|
@ -8,6 +8,7 @@ import Button from 'react-bootstrap/Button';
|
|||||||
import Modal from 'react-bootstrap/Modal';
|
import Modal from 'react-bootstrap/Modal';
|
||||||
import { Alert, Snackbar, IconButton } from '@mui/material';
|
import { Alert, Snackbar, IconButton } from '@mui/material';
|
||||||
import { AddCircleOutline, Delete, Edit } from '@mui/icons-material';
|
import { AddCircleOutline, Delete, Edit } from '@mui/icons-material';
|
||||||
|
import AlbumForm from '@/app/components/forms/album.form';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [albums, setAlbums] = useState<Album[]>([]);
|
const [albums, setAlbums] = useState<Album[]>([]);
|
||||||
@ -181,23 +182,7 @@ export default function Page() {
|
|||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit}>
|
<AlbumForm />
|
||||||
<input name="id" id="album-id" value={formAlbumId} type="hidden" />
|
|
||||||
<div className="row">
|
|
||||||
<div className="six columns">
|
|
||||||
<label htmlFor="album-title">Album Title</label><input type="text" name="title" id="album-title" defaultValue={formAlbumTitle} />
|
|
||||||
</div>
|
|
||||||
<div className="six columns">
|
|
||||||
<label htmlFor="album-artist">Artist</label><input type="text" name="artist" id="album-artist" defaultValue={formAlbumArtist} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="row">
|
|
||||||
<div className="six columns">
|
|
||||||
<label htmlFor="album-genre">Genre</label><input type="text" name="genre" id="album-genre" defaultValue={formAlbumGenre} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" type="submit">{formModalButtonLabel}</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
|
76
frontend/src/app/components/forms/album.form.tsx
Normal file
76
frontend/src/app/components/forms/album.form.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import Form from 'next/form';
|
||||||
|
import Button from "react-bootstrap/Button";
|
||||||
|
import React, {FormEvent, useState} from "react";
|
||||||
|
import {createAlbum, getAlbums, revalidateAlbums, updateAlbum} from "@/app/actions";
|
||||||
|
|
||||||
|
export default function AlbumForm() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
artist: '',
|
||||||
|
genre: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
[name]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React<FormEvent>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
if (formData.id == "") {
|
||||||
|
await createAlbum(formData)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.messages) {
|
||||||
|
// handleSnackbar(`Failed to create Album with ID ${formData.id}: ${JSON.stringify(response)}`, false);
|
||||||
|
} else {
|
||||||
|
// handleSnackbar(`Successfully created Album with ID ${formData.id}`, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// handleSnackbar(`Failed to create Album with ID ${formData.id}: ${JSON.stringify(error)}`, false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await updateAlbum(formData)
|
||||||
|
.then(() => {
|
||||||
|
// handleSnackbar(`Successfully updated Album with ID ${formData.id}`, true);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// handleSnackbar(`Failed to update Album with ID ${formData.id}: ${JSON.stringify(error)}`, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
revalidateAlbums();
|
||||||
|
const data = await getAlbums();
|
||||||
|
// setAlbums(data);
|
||||||
|
} catch (error) {
|
||||||
|
// handleSnackbar(`Error creating Album: ${JSON.stringify(error)}`, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<input name="id" id="album-id" value={formData.id} type="hidden" />
|
||||||
|
<div className="row">
|
||||||
|
<div className="six columns">
|
||||||
|
<label htmlFor="album-title">Album Title</label>
|
||||||
|
<input type="text" name="title" id="album-title" onChange={handleChange} defaultValue={formData.title} />
|
||||||
|
</div>
|
||||||
|
<div className="six columns">
|
||||||
|
<label htmlFor="album-artist">Artist</label>
|
||||||
|
<input type="text" name="artist" id="album-artist" onChange={handleChange} defaultValue={formData.artist} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="six columns">
|
||||||
|
<label htmlFor="album-genre">Genre</label>
|
||||||
|
<input type="text" name="genre" id="album-genre" onChange={handleChange} defaultValue={formData.genre} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" type="submit">Submit</Button>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user